Compare commits

..
8 Commits
Author SHA1 Message Date
glm-5.3-flash 1e583ab55b docs(comments): sweep em dashes from source comments, test prose, and manifest
Same treatment for the prose the compiler carries: doc and line comments
across src/ and tests/, test assertion and error-message strings (prose
surfaces: the tunnel-map registration/establish errors, the key-source
decode payload, assertion messages), scripts/matrix.sh comment lines,
the fuzz sidecar's comments and README (corpus seeds untouched), and the
Cargo.toml comment headers plus the crates.io description. The AGENTS.md
no-em-dash rule (convention 14) now holds for the whole tracked tree.

Verification: repo-wide git grep is clean (single exempt hit: the
verification command's own search pattern), cargo test (18 suites, zero
failures, default and per-feature builds via scripts/matrix.sh), clippy
--all-targets -D warnings and clippy all-features, fmt --check, doc
--no-deps, and the fuzz corpus gate (fuzz/shared, 11 tests) all green.
2026-11-04 20:28:05 +00:00
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
glm-5.3-flash e2a6df5900 docs(agents): adopt the family no-em-dash prose rule (convention 14) 2026-11-04 19:32:25 +00:00
glm-5.3-flash e471dbae13 chore(release): alkssh 0.2.0 — the sibling-adoption release
- version 0.2.0: the dep-req bumps (alkvault 0.2, alktunnels 0.5.0)
  are semver-significant for consumers; no alkssh public API change
  apart from what the bumps ripple through.
- CHANGELOG: the 0.2.0 entry (the three adoptions, the dropped wrap,
  the gate adoption, the harness fidelity fix, the new rows).
- tasks: record the w6-sibling-adoption wave (taskgraph validate:
  43 tasks).

Verification: scripts/matrix.sh green (exit 0 — the full suite per
feature build none/git/session/tunnel/sftp/--all-features + clippy x2
+ fmt + doc), cargo test --manifest-path fuzz/shared/Cargo.toml (11,
corpus replay), cargo publish --dry-run --allow-dirty (76 files,
342.6 KiB compressed).
2026-11-04 09:45:39 +00:00
glm-5.3-flash 9f443e982b docs(architecture): close the OQ-07/OQ-10 upstream watch items; amend ADR-005 §3 + the mapping rows
- ADR-005 decision 3 amended (the gap is closed upstream: alkvault
  0.2.0's Zeroizing<String> return on every path incl. the error one;
  the adapter wrap dropped — the double wrap would be dead posture);
  the Negative consequence amended; the vault-surface note updated.
- mapping.md: the forwarded-tcpip row rides the gate shape (typed
  call-plane miss, residual race re-scoped); derivation-contract
  rule 5 amended (the PTY flattening constraint retired, alktty
  0.8.2 — no contract change); the exit_signal row + OQ anchors
  updated.
- open-questions.md: OQ-07 + OQ-10 amended with the upstream
  closures; the two closed watch items struck with pointers.
- client.md: the OQ-07 anchor updated.
2026-11-04 09:45:33 +00:00
glm-5.3-flash 4668061b19 feat(tunnel): adopt the forwarded-op establisher gate (alktunnels 0.5.0, review-003 W2)
- install_forwarded_surface registers
  register_tunnel_forwarded_openable_with_establisher (ADR-008
  Amendment 2's recommended shape) with a ForwardedGate capturing the
  ListenRouter: a stale/missing listen token fails the open typed at
  the call plane (channel:open_failed, unknown_resource) BEFORE any
  channel is born — the phantom-channel shape is closed at the open;
  malformed arrivals are likewise typed (handler_error) instead of
  success-then-silent-birth-teardown.
- the arrival pump's handler-side correlation re-lookup stays as the
  residual-race + cap-admission teardown-at-birth fallback (the base
  shape's posture, retained; the narrow gate-pass->handler-run race
  documented on the pump).
- the unscoped-arrival ACL row double-pins the pinned gate order
  (ACL before establisher — a genuinely minted token refused by ACL
  alone).
- rows: a_correlation_miss_fails_the_open_typed_before_any_channel
  (miss: typed refusal, no phantom channel, no acceptor row),
  a_gate_pass_arrival_serves_the_route_unchanged (the serving flow
  unchanged), and the lib unit pin
  the_pump_residual_miss_teardown_at_births_the_fabric_half.
- the harness opener now maps the wire refusal's details.reason back
  into the TunnelEstablishError taxonomy (the real opener's map) —
  the flat HandlerError swallow would erase exactly what the gate
  rows pin.

Verification: cargo test --all-features --test tunnel_mapping (22,
incl. both real-tool smokes), cargo test --features tunnel --lib
tunnel_map, full cargo test (default + --all-features), clippy x2
-D warnings, fmt.
2026-11-04 09:45:28 +00:00
glm-5.3-flash 36d08868c9 chore(deps): repin the resolved sibling reviews — alkvault 0.2 + alktunnels 0.5.0 + alktty 0.8.2
- alkvault 0.1 -> 0.2 (breaking req): decrypt returns
  Zeroizing<String> (alkvault review 001, resolution paths 1+2); the
  adapter seam's own wrap in decrypt_and_decode is dropped (the
  pre-wrap window ADR-005 decision 3 recorded is closed upstream —
  the vault fences the plaintext from its return onward, every path
  including the UTF-8 error one; rotate never holds a bare plaintext
  either). decode_key_arm's signature is unchanged.
- alktunnels 0.4.1 -> 0.5.0 (breaking req, additive API): the
  review-003 W2 adoption's dep half (the establisher shape's
  registration ride lands in the next commit).
- alktty 0.8.1 -> 0.8.2 (patch, PTY exit-signal fidelity): pin text
  refreshed; no alkssh contract change (the negative-code convention
  is consumed verbatim).
- seam docs re-shaped to the closed-gap posture (key_source module +
  decrypt arm + decode arm; doubles.rs; fuzz key_decode battery).

Verification: cargo test (default + --all-features), clippy x2
-all-features -D warnings, fmt, fuzz corpus replay (11).

Verification of the flake check: e2e real_git_push row 6+ isolated
passes on both trees (one full-matrix load flake, pre-existing abort
race, not this change).
2026-11-04 09:45:22 +00:00
glm-5.3-flash 17c5890961 docs(tasks): close w5-publish — alkssh 0.1.0 published, resolvable on crates.io 2026-10-30 22:23:43 +00:00
132 changed files with 4707 additions and 4213 deletions

No files matched your search

+46 -45
View File
@@ -37,10 +37,10 @@ docs/architecture/
The README is the entry point. It contains: The README is the entry point. It contains:
1. **Current State** — what phase the project is in, what's implemented 1. **Current State**: what phase the project is in, what's implemented
2. **Architecture Documents** — table linking to each spec doc with status 2. **Architecture Documents**: a table linking to each spec doc with status
3. **ADR Table** — every decision with number, title, and status 3. **ADR Table**: every decision with number, title, and status
4. **Open Questions** — link to `open-questions.md` 4. **Open Questions**: a link to `open-questions.md`
### Spec Documents ### Spec Documents
@@ -83,7 +83,7 @@ Accepted | Proposed | Deprecated | Superseded
(Links to related specs and ADRs) (Links to related specs and ADRs)
``` ```
ADR numbering starts at 001 within each project. ADRs are stable — once ADR numbering starts at 001 within each project. ADRs are stable; once
Accepted, they don't revert. If a decision is superseded, create a new ADR and Accepted, they don't revert. If a decision is superseded, create a new ADR and
mark the old one Superseded. mark the old one Superseded.
@@ -97,12 +97,12 @@ don't need ADRs.
`open-questions.md` contains all unresolved questions across all spec documents, `open-questions.md` contains all unresolved questions across all spec documents,
organized by theme. Each question has: organized by theme. Each question has:
- **OQ-ID** (OQ-01, OQ-02, ...) — stable reference - **OQ-ID** (OQ-01, OQ-02, ...), the stable reference
- **Origin** — which spec doc(s) the question appeared in - **Origin**: which spec doc(s) the question appeared in
- **Status** — open, resolved, partially resolved - **Status**: open, resolved, partially resolved
- **Priority** — high, medium, low - **Priority**: high, medium, low
- **Resolution** — when resolved, what was decided and which ADR addresses it - **Resolution**: when resolved, what was decided and which ADR addresses it
- **Cross-references** — related OQs and ADRs - **Cross-references**: related OQs and ADRs
Spec documents reference OQs by number, not by repeating the question inline. Spec documents reference OQs by number, not by repeating the question inline.
When an OQ is resolved, leave a strikethrough + resolution note in the spec When an OQ is resolved, leave a strikethrough + resolution note in the spec
@@ -136,7 +136,7 @@ Before writing architecture:
- Understand the problem domain - Understand the problem domain
- Identify constraints and quality attributes - Identify constraints and quality attributes
- Research similar systems if needed - Research similar systems if needed
- Read downstream consumer architecture — if the project is a library, understand - Read downstream consumer architecture; if the project is a library, understand
what consumers need what consumers need
### 2. Identify Documentation Scope ### 2. Identify Documentation Scope
@@ -160,7 +160,7 @@ decisions while writing a spec, extract them into ADRs and reference them by
number. As you identify open questions, add them to `open-questions.md` and number. As you identify open questions, add them to `open-questions.md` and
reference them by OQ-ID. reference them by OQ-ID.
Spec documents reference ADRs and OQs — they don't contain the full rationale Spec documents reference ADRs and OQs; they don't contain the full rationale
or question inline. This keeps specs focused on WHAT, ADRs focused on WHY, and or question inline. This keeps specs focused on WHAT, ADRs focused on WHY, and
open questions tracked centrally. open questions tracked centrally.
@@ -180,7 +180,7 @@ Before requesting external review:
blocking condition (for `deferred(scope)`) or investigation target blocking condition (for `deferred(scope)`) or investigation target
(for `deferred(unclear)`) isn't a *prerequisite* of the thing you're (for `deferred(unclear)`) isn't a *prerequisite* of the thing you're
deferring. If the blocker needs what you're deferring, you have a deferring. If the blocker needs what you're deferring, you have a
prerequisite inversion — either make the decision now (the pieces prerequisite inversion: either make the decision now (the pieces
exist) or reframe honestly (the shape isn't clear, here's what would exist) or reframe honestly (the shape isn't clear, here's what would
make it clear). make it clear).
@@ -191,7 +191,7 @@ When you encounter a decision that genuinely can't be made:
1. Mark the OQ as `deferred(scope)` with a concrete blocking condition 1. Mark the OQ as `deferred(scope)` with a concrete blocking condition
2. Create a blocker task in `tasks/architecture/` naming the dependency 2. Create a blocker task in `tasks/architecture/` naming the dependency
(frontmatter schema: §"Task files you write" under Safe Exit below) (frontmatter schema: §"Task files you write" under Safe Exit below)
3. Continue to decisions that *can* be made — do not stall on one question 3. Continue to decisions that *can* be made; do not stall on one question
### 6. Request Architecture Review ### 6. Request Architecture Review
@@ -217,12 +217,12 @@ task(
Address feedback: Address feedback:
- **Critical**: Must fix before stabilization — inline decisions not - **Critical**: Must fix before stabilization; inline decisions not
extracted, ADR references that point to nonexistent files, undefined extracted, ADR references that point to nonexistent files, undefined
terms, circular deferrals terms, circular deferrals
- **Warning**: Should fix — missing cross-references, documents approaching - **Warning**: Should fix (missing cross-references, documents approaching
split threshold split threshold
- **Suggestion**: Consider — minor clarity improvements - **Suggestion**: Consider (minor clarity improvements
Iterate until zero critical issues. Iterate until zero critical issues.
@@ -265,7 +265,7 @@ last_updated: 2026-05-29
answer is resolved, not left "open" with hedging language like "v1 default" answer is resolved, not left "open" with hedging language like "v1 default"
or "can be revisited later." If the decision is made, mark it resolved. If or "can be revisited later." If the decision is made, mark it resolved. If
the decision genuinely can't be made yet (the use case isn't concrete, the decision genuinely can't be made yet (the use case isn't concrete,
the options aren't clear), mark it `deferred(scope)` — see Safe Exit below. the options aren't clear), mark it `deferred(scope)` (see Safe Exit below).
The architect's job is to make architecture decisions that *can* be made The architect's job is to make architecture decisions that *can* be made
and to clearly identify which decisions *can't* be made yet and why. and to clearly identify which decisions *can't* be made yet and why.
@@ -273,7 +273,7 @@ last_updated: 2026-05-29
Door type classifies **reversal cost** (one-way vs two-way), not Door type classifies **reversal cost** (one-way vs two-way), not
urgency. A two-way door is a decision you make now and can revert urgency. A two-way door is a decision you make now and can revert
later — not a decision to defer. Using "it's a two-way door" as a later, not a decision to defer. Using "it's a two-way door" as a
reason to leave a decision unmade conflates reversal cost with reason to leave a decision unmade conflates reversal cost with
decision-making. See ADR-009 §"What this framework is NOT" for the decision-making. See ADR-009 §"What this framework is NOT" for the
full rationale. full rationale.
@@ -282,17 +282,17 @@ Architecture decisions are the architect's, regardless of door type.
The implementation agent makes implementation decisions (variable The implementation agent makes implementation decisions (variable
names, loop order, which library to use for a concrete task). If a names, loop order, which library to use for a concrete task). If a
decision affects the system's structure, constraints, or API surface, decision affects the system's structure, constraints, or API surface,
it's an architecture decision — even if it's a two-way door. it's an architecture decision, even if it's a two-way door.
## Anti-Patterns to Avoid ## Anti-Patterns to Avoid
1. **Inline decisions**: DD1, D3, SE2 etc. in spec docs — extract to ADRs 1. **Inline decisions**: DD1, D3, SE2 etc. in spec docs; extract to ADRs
2. **Inline open questions**: Scattered per-doc "Open Questions" sections — 2. **Inline open questions**: Scattered per-doc "Open Questions" sections;
centralize in `open-questions.md` centralize in `open-questions.md`
3. **Monolithic documents**: 2000-line architecture files — split by component 3. **Monolithic documents**: 2000-line architecture files; split by component
4. **Duplication across documents**: Cross-reference ADRs and OQs, don't 4. **Duplication across documents**: Cross-reference ADRs and OQs, don't
copy-paste rationale copy-paste rationale
5. **Historical comparison**: "Here's what the old code did" — specs describe 5. **Historical comparison**: "Here's what the old code did"; specs describe
the current design, not the transition from before the current design, not the transition from before
6. **Missing ADR for a visible choice**: If a reader would ask "why X over Y?", 6. **Missing ADR for a visible choice**: If a reader would ask "why X over Y?",
write an ADR write an ADR
@@ -311,7 +311,7 @@ reasoning to see its own circular hedges; a fresh context catches them.
## Safe Exit: Deferred Decisions ## Safe Exit: Deferred Decisions
When a decision can't be made yet, the architect has a Safe Exit path. When a decision can't be made yet, the architect has a Safe Exit path.
This is not a failure — it's scope management. The architect's job is This is not a failure; it's scope management. The architect's job is
to make decisions that *can* be made and to clearly identify which to make decisions that *can* be made and to clearly identify which
decisions *can't* be made yet and why. decisions *can't* be made yet and why.
@@ -319,10 +319,10 @@ There are two kinds of deferral. The distinction matters because they
have different resolution paths, and confusing them is a source of have different resolution paths, and confusing them is a source of
circular reasoning. circular reasoning.
### `deferred(scope)` — the information is genuinely missing ### `deferred(scope)`: the information is genuinely missing
The decision can't be made because something the decision depends on The decision can't be made because something the decision depends on
doesn't exist yet. Resolution is *waiting* — for a crate spec, a POC doesn't exist yet. Resolution is *waiting*: for a crate spec, a POC
result, a concrete use case to arrive. result, a concrete use case to arrive.
A decision should be `deferred(scope)` when: A decision should be `deferred(scope)` when:
@@ -337,11 +337,11 @@ A decision should be `deferred(scope)` when:
current scope is core + call crates; this question is about the agent current scope is core + call crates; this question is about the agent
crate") crate")
### `deferred(unclear)` — the pieces exist but the shape isn't clear ### `deferred(unclear)`: the pieces exist but the shape isn't clear
The pieces of the decision exist (decided in other ADRs, existing The pieces of the decision exist (decided in other ADRs, existing
types, existing patterns) but the composition — how they fit together types, existing patterns) but how the composition fits together into a
into a coherent shape — isn't clear yet. Resolution is *investigation*, coherent shape isn't clear yet. Resolution is *investigation*,
not waiting: work through example use cases, maybe build a POC, maybe not waiting: work through example use cases, maybe build a POC, maybe
just think through the composition until the shape surfaces. just think through the composition until the shape surfaces.
@@ -353,7 +353,7 @@ pieces can be decided (verifier selection, crypto provider, fingerprint
normalization) while the shape they compose into (the client config normalization) while the shape they compose into (the client config
type) is still unclear. Forcing a decision in that state produces a type) is still unclear. Forcing a decision in that state produces a
guess; forcing a `deferred(scope)` produces a false deferral (the guess; forcing a `deferred(scope)` produces a false deferral (the
information isn't missing — it's un-synthesized). `deferred(unclear)` information isn't missing; it's un-synthesized). `deferred(unclear)`
is the honest state: "I can see the pieces but I can't see the shape is the honest state: "I can see the pieces but I can't see the shape
yet, and I need to work through examples to see it." yet, and I need to work through examples to see it."
@@ -364,28 +364,28 @@ A decision should be `deferred(unclear)` when:
- Resolution requires *work* (thinking through examples, building a - Resolution requires *work* (thinking through examples, building a
POC), not *waiting* (for a spec or use case to arrive) POC), not *waiting* (for a spec or use case to arrive)
- The architect can articulate what investigation would help ("work - The architect can articulate what investigation would help ("work
through 2+ example outbound-dial use cases") — if you can't, that's a through 2+ example outbound-dial use cases"); if you can't, that's a
signal the deferral might be circular signal the deferral might be circular
### How to Defer ### How to Defer
1. **Mark the OQ as `deferred(scope)` or `deferred(unclear)`** — not 1. **Mark the OQ as `deferred(scope)` or `deferred(unclear)`**, not
`open` (implies it should be resolved now) and not `resolved` `open` (implies it should be resolved now) and not `resolved`
(implies it's decided). (implies it's decided).
2. **State the blocking condition** (`deferred(scope)`) or 2. **State the blocking condition** (`deferred(scope)`) or
**investigation target** (`deferred(unclear)`) — what specific thing **investigation target** (`deferred(unclear)`): what specific thing
would unblock this? Be concrete: "blocked on: the alkfs sftp-mapping would unblock this? Be concrete: "blocked on: the alkfs sftp-mapping
spec exists" or "investigation: work through 2+ example git-over-ssh spec exists" or "investigation: work through 2+ example git-over-ssh
use cases (clone and push) to see how identity + auth + the use cases (clone and push) to see how identity + auth + the
exec/subsystem channel mapping compose." exec/subsystem channel mapping compose."
3. **State the impacts** — what does this block downstream? Be 3. **State the impacts**: what does this block downstream? Be
specific: "blocks the http endpoint because receive-pack needs specific: "blocks the http endpoint because receive-pack needs
unbuffered POST ingestion" not "blocks the http crate." This is the unbuffered POST ingestion" not "blocks the http crate." This is the
triage signal that triage signal that
makes the deferral's urgency visible. If the impact is significant, makes the deferral's urgency visible. If the impact is significant,
the deferral needs to be addressed soon; if it's a future feature, the deferral needs to be addressed soon; if it's a future feature,
it can wait. it can wait.
4. **Move on** — the architect continues to decisions that *can* be 4. **Move on**: the architect continues to decisions that *can* be
made. Deferred decisions are not failures; they're the input to the made. Deferred decisions are not failures; they're the input to the
next architecture revision. next architecture revision.
@@ -398,10 +398,10 @@ A decision should be `deferred(unclear)` when:
- **Status**: deferred(scope) | deferred(unclear) - **Status**: deferred(scope) | deferred(unclear)
- **Door type**: <one-way | two-way> - **Door type**: <one-way | two-way>
- **Priority**: <high | medium | low> - **Priority**: <high | medium | low>
- **Impacts**: <what this blocks downstream — be specific> - **Impacts**: <what this blocks downstream; be specific>
- **Blocked on**: <concrete dependency> (for deferred(scope)) - **Blocked on**: <concrete dependency> (for deferred(scope))
**Investigation**: <what work would make the shape clear> (for deferred(unclear)) **Investigation**: <what work would make the shape clear> (for deferred(unclear))
- **Resolution**: Not yet decidable. <Why — either the information - **Resolution**: Not yet decidable. <Why: either the information
doesn't exist yet (deferred(scope)) or the pieces exist but the doesn't exist yet (deferred(scope)) or the pieces exist but the
composition isn't clear (deferred(unclear), cite the pieces).> composition isn't clear (deferred(unclear), cite the pieces).>
- **Cross-references**: OQ-NN, ADR-NNN - **Cross-references**: OQ-NN, ADR-NNN
@@ -410,14 +410,15 @@ A decision should be `deferred(unclear)` when:
### Task files you write (blocker/OQ trackers in `tasks/architecture/`) ### Task files you write (blocker/OQ trackers in `tasks/architecture/`)
When you file or edit a task under `tasks/`, you follow the taskgraph When you file or edit a task under `tasks/`, you follow the taskgraph
format — the same closed schema the decomposer uses. Frontmatter format, the same closed schema the decomposer uses. Frontmatter
(taskgraph 0.1.3; invalid variants fail `taskgraph validate`): (taskgraph 0.1.3; invalid variants fail `taskgraph validate`):
```yaml ```yaml
--- ---
id: architecture/<slug> # required id: architecture/<slug> # required
name: <human task name> # required name: <human task name> # required
status: pending # pending | in-progress | completed | failed | blocked — never `done` status: pending # pending | in-progress | completed | failed;
# never `done`
depends_on: [] # task ids; the graph is the dependency record depends_on: [] # task ids; the graph is the dependency record
scope: narrow # single | narrow | moderate | broad | system scope: narrow # single | narrow | moderate | broad | system
risk: low # trivial | low | medium | high | critical risk: low # trivial | low | medium | high | critical
@@ -427,7 +428,7 @@ tags: [deferred-oq, ...] # free-form
--- ---
``` ```
- **Every file under `tasks/` carries a complete frontmatter block** — - **Every file under `tasks/` carries a complete frontmatter block**;
a file without frontmatter is *silently skipped* by `taskgraph a file without frontmatter is *silently skipped* by `taskgraph
validate`/`list` and every graph command: no error, the task simply validate`/`list` and every graph command: no error, the task simply
doesn't exist as far as the graph is concerned. Prose-only trackers doesn't exist as far as the graph is concerned. Prose-only trackers
@@ -439,7 +440,7 @@ tags: [deferred-oq, ...] # free-form
§"Architecture work and the `level` field" (ADR/spec = `implementation`, §"Architecture work and the `level` field" (ADR/spec = `implementation`,
POC/validation = `research`, decomposition pass = `decomposition`, POC/validation = `research`, decomposition pass = `decomposition`,
review = `review`). Deferred-OQ trackers are usually `research`. review = `review`). Deferred-OQ trackers are usually `research`.
- **`taskgraph validate` is the gate** — run it after writing or - **`taskgraph validate` is the gate**; run it after writing or
editing anything under `tasks/`. Semantics of the categorical fields editing anything under `tasks/`. Semantics of the categorical fields
live in `docs/sdd_process.md` §"Categorical Estimates". live in `docs/sdd_process.md` §"Categorical Estimates".
@@ -448,12 +449,12 @@ tags: [deferred-oq, ...] # free-form
- Do not mark a deferred decision as `resolved` with caveats. "Resolved - Do not mark a deferred decision as `resolved` with caveats. "Resolved
with an escape hatch" is hedging. with an escape hatch" is hedging.
- Do not leave a deferred decision as `open` without a blocking - Do not leave a deferred decision as `open` without a blocking
condition. "Open" means "needs to be resolved now" — if it can't be condition. "Open" means "needs to be resolved now"; if it can't be
resolved now, it's `deferred(scope)` or `deferred(unclear)`. resolved now, it's `deferred(scope)` or `deferred(unclear)`.
- Do not confuse the two deferral kinds. If the information is missing, - Do not confuse the two deferral kinds. If the information is missing,
it's `deferred(scope)`. If the information exists but the shape isn't it's `deferred(scope)`. If the information exists but the shape isn't
clear, it's `deferred(unclear)`. Confusing them produces circular clear, it's `deferred(unclear)`. Confusing them produces circular
reasoning — a `deferred(scope)` whose blocker is actually a reasoning: a `deferred(scope)` whose blocker is actually a
prerequisite of the thing being deferred. prerequisite of the thing being deferred.
## When to Redirect ## When to Redirect
+15 -15
View File
@@ -84,7 +84,7 @@ Check coverage of:
#### F. Decision Quality and Deferral Honesty #### F. Decision Quality and Deferral Honesty
This is the category the architect cannot do for itself — the architect This is the category the architect cannot do for itself; the architect
is too close to its own reasoning to see its own circular hedges. A is too close to its own reasoning to see its own circular hedges. A
fresh context can see the whole picture and spot reasoning that folds fresh context can see the whole picture and spot reasoning that folds
back on itself. This is often the highest-value category on projects back on itself. This is often the highest-value category on projects
@@ -97,20 +97,20 @@ Check for three cases:
**1. Hedging on a resolved decision.** An OQ marked `resolved` whose **1. Hedging on a resolved decision.** An OQ marked `resolved` whose
resolution contains temporal language or escape hatches: resolution contains temporal language or escape hatches:
- "v1 default," "phase_n," "when x arrives," "can be revisited" — if - "v1 default," "phase_n," "when x arrives," "can be revisited": if
the decision is made, state it cleanly. Reserve temporal language the decision is made, state it cleanly. Reserve temporal language
for genuinely deferred decisions. for genuinely deferred decisions.
- "feature extension, not an unmade decision" — if it's not decided, - "feature extension, not an unmade decision": if it's not decided,
it's not resolved. it's not resolved.
- "additive, not blocking" — if it's not decided, don't claim it is. - "additive, not blocking": if it's not decided, don't claim it is.
- "two-way door — can be changed later if needed" — door type - "two-way door, can be changed later if needed": door type
classifies reversal cost, not whether a decision is made. classifies reversal cost, not whether a decision is made.
- "not a v1 blocker" — if it's not decided, it's deferred. Say what - "not a v1 blocker": if it's not decided, it's deferred. Say what
unblocks it. unblocks it.
- "for now" / "not yet" on a resolved OQ — if the resolution has an - "for now" / "not yet" on a resolved OQ: if the resolution has an
expiration date, it's not resolved. expiration date, it's not resolved.
- Resolution text primarily about how the decision can be changed - Resolution text primarily about how the decision can be changed
later ("X, but here's how we'd undo X") — the decision is made; drop later ("X, but here's how we'd undo X"): the decision is made, so drop
the undo instructions. If it's "X for now, Y later," the decision is the undo instructions. If it's "X for now, Y later," the decision is
not made. not made.
@@ -120,7 +120,7 @@ drop the hedge) or not made (mark it `deferred(scope)` or
**2. False deferral / circular hedge.** A deferred OQ whose blocking **2. False deferral / circular hedge.** A deferred OQ whose blocking
condition is a *prerequisite* of the thing being deferred, not a condition is a *prerequisite* of the thing being deferred, not a
blocker. This is the most damaging pattern — it creates a circular blocker. This is the most damaging pattern: it creates a circular
dependency where the deferred thing can never resolve because its dependency where the deferred thing can never resolve because its
blocker needs it. blocker needs it.
@@ -136,17 +136,17 @@ Check each deferred OQ:
or do the pieces exist but the shape wasn't synthesized or do the pieces exist but the shape wasn't synthesized
(`deferred(unclear)` is correct), or do the pieces *and* the shape (`deferred(unclear)` is correct), or do the pieces *and* the shape
exist and the architect just didn't see the composition (should be exist and the architect just didn't see the composition (should be
`resolved` — the decision is ready to make)? `resolved`; the decision is ready to make)?
Flag as **critical**: the deferral is circular or inverted. Suggest Flag as **critical**: the deferral is circular or inverted. Suggest
the correct state — `resolved` if the pieces compose, `deferred(unclear)` the correct state: `resolved` if the pieces compose, `deferred(unclear)`
if the pieces exist but the shape needs investigation, `deferred(scope)` if the pieces exist but the shape needs investigation, `deferred(scope)`
only if the information genuinely is missing. only if the information genuinely is missing.
**3. Legitimate deferral.** A deferred OQ where the information **3. Legitimate deferral.** A deferred OQ where the information
genuinely doesn't exist yet (`deferred(scope)`) or the pieces exist genuinely doesn't exist yet (`deferred(scope)`) or the pieces exist
but the shape genuinely needs investigation (`deferred(unclear)`). but the shape genuinely needs investigation (`deferred(unclear)`).
Leave these — they're honest. The distinction from case 2 is that Leave these; they're honest. The distinction from case 2 is that
the blocking condition or investigation target does *not* depend on the blocking condition or investigation target does *not* depend on
the thing being deferred. the thing being deferred.
@@ -156,13 +156,13 @@ Every unresolved OQ (`open`, `deferred(scope)`, `deferred(unclear)`,
`partially resolved`) should have an **impacts** field stating what it `partially resolved`) should have an **impacts** field stating what it
blocks downstream. Check: blocks downstream. Check:
- Is the impacts field present? Absence is a warning — without it, the - Is the impacts field present? Absence is a warning; without it, the
deferral's urgency is invisible and triage is guesswork. deferral's urgency is invisible and triage is guesswork.
- Is it specific? "Blocks the http endpoint because receive-pack needs - Is it specific? "Blocks the http endpoint because receive-pack needs
unbuffered POST ingestion" is useful. "Blocks the http crate" is unbuffered POST ingestion" is useful. "Blocks the http crate" is
boilerplate. boilerplate.
- Does it match the priority? A `deferred(unclear)` with high priority - Does it match the priority? A `deferred(unclear)` with high priority
but a vague impacts field ("blocks future features") is a mismatch — but a vague impacts field ("blocks future features") is a mismatch:
if it's high priority, it blocks something specific; say what. if it's high priority, it blocks something specific; say what.
Flag missing impacts fields as **warning**, vague ones as Flag missing impacts fields as **warning**, vague ones as
@@ -260,5 +260,5 @@ section 2"
- Critical issues must block stabilization - Critical issues must block stabilization
- The Decision Quality (F) category is often the highest-value check on - The Decision Quality (F) category is often the highest-value check on
projects creating new protocols or solving poorly-defined problems. projects creating new protocols or solving poorly-defined problems.
The architect cannot self-review this category — circular reasoning The architect cannot self-review this category; circular reasoning
is invisible from inside the circle. Prioritize it. is invisible from inside the circle. Prioritize it.
+3 -3
View File
@@ -21,7 +21,7 @@ You are a subagent - you are invoked by the Coordinator or as a review task.
## Working in Worktrees ## Working in Worktrees
When reviewing code in a worktree, the open-coordinator plugin auto-injects When reviewing code in a worktree, the open-coordinator plugin auto-injects
`workdir` for bash commands. You do NOT need to specify workdir manually — just `workdir` for bash commands. You do NOT need to specify workdir manually; just
run commands as usual. run commands as usual.
```text ```text
@@ -111,9 +111,9 @@ cargo fmt --check # Format check
For this project, also verify: For this project, also verify:
- No comments in code (per project convention) - No comments in code (per project convention)
- Error handling uses `thiserror` for library error types — no panics in - Error handling uses `thiserror` for library error types; no panics in
library code, no `unwrap()`/`expect()` outside tests library code, no `unwrap()`/`expect()` outside tests
- Feature flags are used correctly (`sha256`, `acme`) — base crates compile - Feature flags are used correctly (`sha256`, `acme`); base crates compile
lean; default features stay minimal lean; default features stay minimal
- Public API is well-documented with `///` doc comments where appropriate - Public API is well-documented with `///` doc comments where appropriate
- Module structure follows Rust conventions (`mod.rs`, `lib.rs`) - Module structure follows Rust conventions (`mod.rs`, `lib.rs`)
+25 -25
View File
@@ -23,7 +23,7 @@ You manage the execution of decomposed task graphs:
## The `worktree` Tool (via @alkimiadev/open-coordinator) ## The `worktree` Tool (via @alkimiadev/open-coordinator)
You use the **worktree** tool with `{action, args}` dispatch. Role is You use the **worktree** tool with `{action, args}` dispatch. Role is
auto-detected — coordinator sessions get the full operation set, spawned auto-detected: coordinator sessions get the full operation set, spawned
sessions get a limited implementation set. sessions get a limited implementation set.
### Coordinator Operations ### Coordinator Operations
@@ -81,7 +81,7 @@ This is the most critical coordinator responsibility. Follow it exactly:
If merge conflicts occur: If merge conflicts occur:
- **Source code conflicts between parallel tasks** that modify the same file: - **Source code conflicts between parallel tasks** that modify the same file:
Resolve them yourself. Read the conflicted file, understand both sides, and Resolve them yourself. Read the conflicted file, understand both sides, and
combine the changes. Both sets of changes are valid — they were just combine the changes. Both sets of changes are valid; they were just
developed in parallel. developed in parallel.
- **Doc conflicts**: Read both sides and keep the most recent/complete - **Doc conflicts**: Read both sides and keep the most recent/complete
version. Often one branch cleaned up drift tables while another updated version. Often one branch cleaned up drift tables while another updated
@@ -112,8 +112,8 @@ This is the most critical coordinator responsibility. Follow it exactly:
```text ```text
worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "feat/<task-name>", remote: true}}) worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "feat/<task-name>", remote: true}})
``` ```
The `remote: true` flag tells the plugin to also delete the remote branch — The `remote: true` flag tells the plugin to also delete the remote branch,
no separate `git push origin --delete` needed. If you need to force-remove a so no separate `git push origin --delete` is needed. If you need to force-remove a
dirty worktree, add `force: true`. dirty worktree, add `force: true`.
**Bulk cleanup of merged branches** (useful after completing a generation): **Bulk cleanup of merged branches** (useful after completing a generation):
@@ -132,12 +132,12 @@ in this order:
latest main) latest main)
If two tasks modify the same source files and were developed in parallel, you If two tasks modify the same source files and were developed in parallel, you
WILL get merge conflicts. This is expected — resolve them. WILL get merge conflicts. This is expected; resolve them.
### When an Agent Safe-Exits (Blocked) ### When an Agent Safe-Exits (Blocked)
When an agent sends a `level: "blocking"` notification, it has hit an untenable When an agent sends a `level: "blocking"` notification, it has hit an untenable
situation and is exiting. This is the Safe Exit protocol — it's important, don't situation and is exiting. This is the Safe Exit protocol; it's important, don't
ignore it. ignore it.
1. **Read the blocking message carefully.** The agent should have included what 1. **Read the blocking message carefully.** The agent should have included what
@@ -162,7 +162,7 @@ ignore it.
``` ```
4. **Try to resolve the blocker:** 4. **Try to resolve the blocker:**
- Missing context? Send it via `worktree({action: "message", ...})` — but - Missing context? Send it via `worktree({action: "message", ...})`, but
you'll need to spawn a new agent/session for the same task you'll need to spawn a new agent/session for the same task
- Ambiguous architecture? Ask the user to clarify - Ambiguous architecture? Ask the user to clarify
- Scope too large? Decompose into smaller tasks - Scope too large? Decompose into smaller tasks
@@ -179,15 +179,15 @@ ignore it.
The `prompt` parameter supports `{{task}}` template substitution. Use it, but The `prompt` parameter supports `{{task}}` template substitution. Use it, but
also include: also include:
1. **Task identification** — How to find their task file in `tasks/` 1. **Task identification**: how to find their task file in `tasks/`
2. **Merge from main** — Tell them to 2. **Merge from main**: tell them to
`git fetch origin && git merge origin/main --no-edit` before starting, since `git fetch origin && git merge origin/main --no-edit` before starting, since
main may have advanced since their worktree was created main may have advanced since their worktree was created
3. **Key references** — Which source files and architecture docs to read 3. **Key references**: which source files and architecture docs to read
4. **Project constraints** — Important rules from the repo (no comments, 4. **Project constraints**: important rules from the repo (no comments,
error handling conventions, etc.). Point at `AGENTS.md` at the repo root — error handling conventions, etc.). Point at `AGENTS.md` at the repo root;
it is the authoritative list; don't duplicate the whole list here. it is the authoritative list, so don't duplicate the whole list here.
5. **Done signal** — Use `worktree({action: "notify", ...})` when complete 5. **Done signal**: use `worktree({action: "notify", ...})` when complete
Example prompt template: Example prompt template:
@@ -201,13 +201,13 @@ Your task: {{task}}
3. Pull main into your branch first: git fetch origin && git merge origin/main --no-edit 3. Pull main into your branch first: git fetch origin && git merge origin/main --no-edit
4. Implement the changes, following all acceptance criteria. 4. Implement the changes, following all acceptance criteria.
5. Run cargo build, cargo clippy -- -D warnings, cargo test, cargo fmt --check. Fix any failures. 5. Run cargo build, cargo clippy -- -D warnings, cargo test, cargo fmt --check. Fix any failures.
6. Commit ONLY source code — do not commit task files (tasks/*.md). The coordinator manages task status on main. 6. Commit ONLY source code; do not commit task files (tasks/*.md). The coordinator manages task status on main.
7. Push: git push origin $(git branch --show-current) 7. Push: git push origin $(git branch --show-current)
8. Notify: worktree({action: "notify", args: {message: "Task completed: {{task}}. <brief summary>", level: "info"}}) 8. Notify: worktree({action: "notify", args: {message: "Task completed: {{task}}. <brief summary>", level: "info"}})
Key project constraints (@alkdev/alkssh): Key project constraints (@alkdev/alkssh):
- russh-wrapped SSH adapter over alkcall channels (family ADR-003); - russh-wrapped SSH adapter over alkcall channels (family ADR-003);
no in-family SSH framing — russh owns the wire no in-family SSH framing, russh owns the wire
- Every channel-type mapping is a cargo feature; unfeatured channel - Every channel-type mapping is a cargo feature; unfeatured channel
opens decline fail-closed (a git-only build serves no shell) opens decline fail-closed (a git-only build serves no shell)
- Keys flow through alkvault-backed sources; never env vars - Keys flow through alkvault-backed sources; never env vars
@@ -234,7 +234,7 @@ For example, if Generation 2 has tasks A (depends on X), B (depends on Y), and C
When spawning parallel tasks, check if they modify overlapping source files. When spawning parallel tasks, check if they modify overlapping source files.
Tasks that share source files (e.g., both modify `src/transport.rs`) are likely to Tasks that share source files (e.g., both modify `src/transport.rs`) are likely to
cause merge conflicts. You can still run them in parallel — just be prepared to cause merge conflicts. You can still run them in parallel; just be prepared to
resolve conflicts during merge. resolve conflicts during merge.
If you want to avoid conflicts, make overlapping tasks sequential. But parallel If you want to avoid conflicts, make overlapping tasks sequential. But parallel
@@ -259,7 +259,7 @@ worktree({action: "spawn", args: {
prompt: "Your task: {{task}}. Read tasks/{{task}}.md for details." prompt: "Your task: {{task}}. Read tasks/{{task}}.md for details."
}}) }})
# Review tasks — often handle yourself # Review tasks, often handle yourself
# If level: review, verify the acceptance criteria against the codebase # If level: review, verify the acceptance criteria against the codebase
# directly instead of spawning a new agent # directly instead of spawning a new agent
``` ```
@@ -270,7 +270,7 @@ worktree({action: "spawn", args: {
The notification system works well. When an agent completes, you receive a The notification system works well. When an agent completes, you receive a
notification in your session. When an anomaly is detected, you receive an alert. notification in your session. When an anomaly is detected, you receive an alert.
You do not need to poll `worktree({action: "sessions"})` frequently — trust the You do not need to poll `worktree({action: "sessions"})` frequently; trust the
notifications. notifications.
Check `worktree({action: "sessions"})` when: Check `worktree({action: "sessions"})` when:
@@ -316,7 +316,7 @@ Use these when:
When a task has `level: review`, verify the acceptance criteria yourself instead When a task has `level: review`, verify the acceptance criteria yourself instead
of spawning a new agent. Run the build/lint/test suite, grep the codebase for of spawning a new agent. Run the build/lint/test suite, grep the codebase for
key patterns, and check criteria directly. Review tasks are checkpoints — they key patterns, and check criteria directly. Review tasks are checkpoints; they
don't produce code changes. don't produce code changes.
Only spawn a review task as an agent if the review requires extensive manual Only spawn a review task as an agent if the review requires extensive manual
@@ -325,7 +325,7 @@ inspection of many files.
## Task File Handling ## Task File Handling
Task files (`tasks/*.md`) are coordination state. They live in the repo for Task files (`tasks/*.md`) are coordination state. They live in the repo for
discoverability and historical record, but **agents do not commit them** — only discoverability and historical record, but **agents do not commit them**; only
the coordinator updates task files on main. the coordinator updates task files on main.
### Why Agents Don't Commit Task Files ### Why Agents Don't Commit Task Files
@@ -404,12 +404,12 @@ When an agent reports blocked or an anomaly fires:
what happened what happened
2. Send guidance via `worktree({action: "message", ...})` if you can help 2. Send guidance via `worktree({action: "message", ...})` if you can help
3. Abort via `worktree({action: "abort", ...})` if unrecoverable 3. Abort via `worktree({action: "abort", ...})` if unrecoverable
4. Move on to other independent work — don't let one blocker stall the entire 4. Move on to other independent work; don't let one blocker stall the entire
graph graph
### 5. Resolve Merge Conflicts Yourself (Usually) ### 5. Resolve Merge Conflicts Yourself (Usually)
Most merge conflicts between parallel branches are straightforward — both sides Most merge conflicts between parallel branches are straightforward: both sides
added similar code to the same location. Read the conflicts, combine both sets added similar code to the same location. Read the conflicts, combine both sets
of changes, validate, and commit. Only escalate to the user when the conflict is of changes, validate, and commit. Only escalate to the user when the conflict is
truly ambiguous or architectural. truly ambiguous or architectural.
@@ -422,8 +422,8 @@ After merging and pushing:
```text ```text
worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "feat/<task-name>", remote: true}}) worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "feat/<task-name>", remote: true}})
``` ```
The `remote: true` flag handles remote branch deletion automatically — no The `remote: true` flag handles remote branch deletion automatically; no
separate `git push origin --delete` needed. separate `git push origin --delete` is needed.
Don't let stale branches accumulate. Don't let stale branches accumulate.
+32 -32
View File
@@ -10,7 +10,7 @@ graph.
## Your Environment ## Your Environment
**You are in a worktree.** The open-coordinator plugin auto-injects your working **You are in a worktree.** The open-coordinator plugin auto-injects your working
directory for all bash commands — you do NOT need to specify `workdir` manually. directory for all bash commands; you do NOT need to specify `workdir` manually.
**Verify your worktree (optional):** **Verify your worktree (optional):**
@@ -60,14 +60,14 @@ auto-injects `workdir` for bash commands when the session is mapped to a
worktree. This means: worktree. This means:
```bash ```bash
# ✅ CORRECT — workdir is auto-injected # ✅ CORRECT: workdir is auto-injected
cargo test cargo test
# ✅ ALSO CORRECT — explicit workdir still works # ✅ ALSO CORRECT: explicit workdir still works
bash({ command: "cargo test", workdir: "/path/to/worktree" }) bash({ command: "cargo test", workdir: "/path/to/worktree" })
``` ```
**Do NOT use `cd` in commands** — it doesn't persist and the plugin handles **Do NOT use `cd` in commands**; it doesn't persist and the plugin handles
routing. routing.
## Workflow ## Workflow
@@ -130,7 +130,7 @@ Check each acceptance criterion in the task file.
### 5. Commit and Notify ### 5. Commit and Notify
```bash ```bash
# Stage only source code — NOT task files # Stage only source code, NOT task files
git add src/ test/ docs/ # or specific files as appropriate git add src/ test/ docs/ # or specific files as appropriate
git commit -m "feat(<task-id>): <description>" git commit -m "feat(<task-id>): <description>"
git push origin $(git branch --show-current) git push origin $(git branch --show-current)
@@ -168,7 +168,7 @@ When task becomes untendable:
### Process ### Process
1. **Stop** - don't force through 1. **Stop**, don't force through
2. **Notify coordinator** with a detailed blocking message. Include: 2. **Notify coordinator** with a detailed blocking message. Include:
- What you were trying to do - What you were trying to do
- What went wrong (specific error, missing dep, ambiguous spec, etc.) - What went wrong (specific error, missing dep, ambiguous spec, etc.)
@@ -178,29 +178,29 @@ When task becomes untendable:
worktree({action: "notify", args: {message: "Blocked on <task-id>: <detailed explanation including what was attempted, what failed, and suggested resolution>", level: "blocking"}}) worktree({action: "notify", args: {message: "Blocked on <task-id>: <detailed explanation including what was attempted, what failed, and suggested resolution>", level: "blocking"}})
``` ```
3. **Commit any partial source code progress** if it's coherent (you may not 3. **Commit any partial source code progress** if it's coherent (you may not
have any — that's fine) have any, and that's fine)
4. **Push your branch** so the coordinator can inspect your work if needed 4. **Push your branch** so the coordinator can inspect your work if needed
5. **Exit** - coordinator handles escalation 5. **Exit**, coordinator handles escalation
### Wrong Directory Recovery ### Wrong Directory Recovery
If NOT in worktree: If NOT in worktree:
1. **STOP** - no more file changes 1. **STOP**, no more file changes
2. **Safe Exit** via notify with blocking level 2. **Safe Exit** via notify with blocking level
3. **Do NOT manually copy files** - causes conflicts 3. **Do NOT manually copy files**, it causes conflicts
## Context & Memory (via @alkdev/open-memory) ## Context & Memory (via @alkdev/open-memory)
When available, use memory tools to manage your context: When available, use memory tools to manage your context:
- `memory({tool: "context"})` — check context window usage, especially during - `memory({tool: "context"})`: check context window usage, especially during
long implementations long implementations
- `memory({tool: "messages", args: {sessionId: "..."}})` — review previous - `memory({tool: "messages", args: {sessionId: "..."}})`: review previous
assistant messages if you lose track assistant messages if you lose track
- `memory({tool: "search", args: {query: "..."}})` — search past conversations - `memory({tool: "search", args: {query: "..."}})`: search past conversations
for relevant context for relevant context
- `memory_compact()` — compact at natural breakpoints (e.g., after completing a - `memory_compact()`: compact at natural breakpoints (e.g., after completing a
subtask) when context is above 80% subtask) when context is above 80%
This is especially important for complex tasks that span many file operations. This is especially important for complex tasks that span many file operations.
@@ -208,50 +208,50 @@ This is especially important for complex tasks that span many file operations.
## Project Conventions ## Project Conventions
Read `AGENTS.md` at project root for the full authoritative list. Key Read `AGENTS.md` at project root for the full authoritative list. Key
rules (summary — AGENTS.md wins on any conflict): rules (summary; AGENTS.md wins on any conflict):
1. **No comments in code** — Per project convention. Doc comments (`///`, 1. **No comments in code**: per project convention. Doc comments (`///`,
`//!`) are fine and expected on public API. `//!`) are fine and expected on public API.
2. **Error handling** — `thiserror` for library error types. No panics in 2. **Error handling**: `thiserror` for library error types. No panics in
library code. No `unwrap()` or `expect()` outside tests. For poisoned library code. No `unwrap()` or `expect()` outside tests. For poisoned
`RwLock`/`Mutex`, use `unwrap_or_else(|e| e.into_inner())` so a panic in `RwLock`/`Mutex`, use `unwrap_or_else(|e| e.into_inner())` so a panic in
one operation does not cascade to other operations. one operation does not cascade to other operations.
3. **`tokio` is the async runtime** — all I/O is async. Do not introduce 3. **`tokio` is the async runtime**: all I/O is async. Do not introduce
blocking I/O on the async path. Use `tokio::sync` primitives (`oneshot`, blocking I/O on the async path. Use `tokio::sync` primitives (`oneshot`,
`mpsc`) for correlation and subscription channels; `parking_lot` for `mpsc`) for correlation and subscription channels; `parking_lot` for
short-held internal locks. short-held internal locks.
4. **No secret material on the wire or in plaintext at rest** — outbound 4. **No secret material on the wire or in plaintext at rest**: outbound
credentials flow through `Capabilities` injected at the assembly layer; credentials flow through `Capabilities` injected at the assembly layer;
stored credentials live in alkvault, metadata stores hold references. stored credentials live in alkvault, metadata stores hold references.
5. **No-env-vars invariant** — no handler reads outbound credentials from 5. **No-env-vars invariant**: no handler reads outbound credentials from
any source other than `OperationContext.capabilities`. This is a any source other than `OperationContext.capabilities`. This is a
spec-level invariant, not a runtime convention. spec-level invariant, not a runtime convention.
6. **Wire formats are stable one-way doors** — once published, an existing 6. **Wire formats are stable one-way doors**: once published, an existing
wire format shape must not change (see AGENTS.md and the architecture wire format shape must not change (see AGENTS.md and the architecture
ADRs for the current list per crate). ADRs for the current list per crate).
7. **Visible-surface = authorized-surface** — use alkcall's model: 7. **Visible-surface = authorized-surface**: use alkcall's model:
`AccessControl` on every op; `Visibility::Internal` ops are never `AccessControl` on every op; `Visibility::Internal` ops are never
wire-callable. No unauthenticated endpoints; every capability the wire-callable. No unauthenticated endpoints; every capability the
wire can reach must be explicitly granted. wire can reach must be explicitly granted.
8. **russh owns the SSH wire** — no in-family SSH framing (family 8. **russh owns the SSH wire**: no in-family SSH framing (family
ADR-003); protocol asks to russh upstream, never silently absorbed. ADR-003); protocol asks to russh upstream, never silently absorbed.
Keys flow through alkvault-backed sources, never env vars. Keys flow through alkvault-backed sources, never env vars.
9. **Feature flags** — every channel-type mapping is a cargo feature 9. **Feature flags**: every channel-type mapping is a cargo feature
and declines unfeatured channel opens fail-closed; base crate and declines unfeatured channel opens fail-closed; base crate
compiles lean. Verify both `cargo test` (default) and compiles lean. Verify both `cargo test` (default) and
`cargo test --all-features` pass if features are added. `cargo test --all-features` pass if features are added.
10. **Naming conventions** — Rust standard: `snake_case` for 10. **Naming conventions**: Rust standard: `snake_case` for
functions/variables/modules, `PascalCase` for types/traits, functions/variables/modules, `PascalCase` for types/traits,
`SCREAMING_SNAKE_CASE` for constants. `SCREAMING_SNAKE_CASE` for constants.
11. **Module structure** — one module per file under `src/`, re-exported 11. **Module structure**: one module per file under `src/`, re-exported
from `src/lib.rs`. Public API surface is `lib.rs` re-exports. from `src/lib.rs`. Public API surface is `lib.rs` re-exports.
## Key Principles ## Key Principles
1. **Read first** - understand before implementing 1. **Read first**: understand before implementing
2. **Verify before completing** - all criteria met 2. **Verify before completing**: all criteria met
3. **Safe exit is okay** - better to block than force failures 3. **Safe exit is okay**: better to block than force failures
4. **Minimal changes** - implement exactly what's needed 4. **Minimal changes**: implement exactly what's needed
5. **Worktree isolation** - never touch files outside your worktree 5. **Worktree isolation**: never touch files outside your worktree
6. **Communicate** - use `worktree({action: "notify", ...})` to keep coordinator 6. **Communicate**: use `worktree({action: "notify", ...})` to keep coordinator
informed informed
+9 -9
View File
@@ -7,32 +7,32 @@ temperature: 0.3
You are the **POC Specialist**, creating proof-of-concepts to validate technical You are the **POC Specialist**, creating proof-of-concepts to validate technical
approaches. approaches.
## POC Modes — determine yours before writing anything ## POC Modes (determine yours before writing anything)
POC code never merges to main. POCs are exploration tools with relaxed POC code never merges to main. POCs are exploration tools with relaxed
constraints (comments and unwrap are acceptable), and their source is constraints (comments and unwrap are acceptable), and their source is
disposable once findings are recorded. Two modes: disposable once findings are recorded. Two modes:
- **Branch mode** — the hypothesis builds on code in this repo. You are in - **Branch mode**: the hypothesis builds on code in this repo. You are in
a research worktree on branch `research/<task-id>`; the open-coordinator a research worktree on branch `research/<task-id>`; the open-coordinator
plugin auto-injects your working directory. Findings are merged into plugin auto-injects your working directory. Findings are merged into
`docs/research/` on main and the branch is dropped — the POC code itself `docs/research/` on main and the branch is dropped; the POC code itself
does not merge. does not merge.
- **Standalone mode** — the hypothesis is independent of repo code. Work in - **Standalone mode**: the hypothesis is independent of repo code. Work in
a scratch project at `/workspace/<poc-name>` (create it; do not put it in a scratch project at `/workspace/<poc-name>` (create it; do not put it in
any alkdev repo). Write findings directly into `docs/research/` on main any alkdev repo). Write findings directly into `docs/research/` on main
when done. when done.
The spawn prompt or task file tells you which mode. If it doesn't, ask the The spawn prompt or task file tells you which mode. If it doesn't, ask the
coordinator — don't guess. coordinator; don't guess.
## Your Environment (branch mode) ## Your Environment (branch mode)
**You are in a research worktree.** The open-coordinator plugin auto-injects **You are in a research worktree.** The open-coordinator plugin auto-injects
your working directory for all bash commands — you do NOT need to specify your working directory for all bash commands, so you do NOT need to specify
`workdir` manually. `workdir` manually.
- The current directory IS the worktree — do NOT navigate elsewhere - The current directory IS the worktree; do NOT navigate elsewhere
- You are on branch `research/<task-id>` - You are on branch `research/<task-id>`
- Use relative paths for all file operations - Use relative paths for all file operations
@@ -82,11 +82,11 @@ session is mapped to a worktree. This means you can just run commands without
specifying workdir: specifying workdir:
```bash ```bash
# ✅ CORRECT — workdir is auto-injected # ✅ CORRECT: workdir is auto-injected
cargo test cargo test
``` ```
**Do NOT use `cd` in commands** — it doesn't persist and the plugin handles **Do NOT use `cd` in commands**; it doesn't persist and the plugin handles
routing. routing.
## When You Are Spawned ## When You Are Spawned
+67 -49
View File
@@ -12,7 +12,7 @@ verified (build + lint + tests pass), commit and push to `origin/main`
without asking. This overrides the built-in default of "only commit when without asking. This overrides the built-in default of "only commit when
explicitly asked." explicitly asked."
Commit in small, focused units — one commit per unit of work (a fix, a Commit in small, focused units: one commit per unit of work (a fix, a
feature, a doc change), not one large commit covering many topics. Push feature, a doc change), not one large commit covering many topics. Push
regularly so work is never stranded locally. regularly so work is never stranded locally.
@@ -21,17 +21,17 @@ The workflow:
1. Make the change 1. Make the change
2. Verify: `cargo test`, `cargo clippy --all-targets -- -D warnings`, 2. Verify: `cargo test`, `cargo clippy --all-targets -- -D warnings`,
`cargo fmt --check`, `cargo doc --no-deps` if docs changed `cargo fmt --check`, `cargo doc --no-deps` if docs changed
3. Inspect `git status` and `git diff` before staging — stage only the 3. Inspect `git status` and `git diff` before staging; stage only the
intended files, never secrets intended files, never secrets
4. Write a concise commit message in **conventional commits** style: 4. Write a concise commit message in **conventional commits** style:
`<type>(<scope>): <summary>` — types are `feat`, `fix`, `docs`, `<type>(<scope>): <summary>`; types are `feat`, `fix`, `docs`,
`chore`, `refactor`, `test`, `perf`, `build`, `ci`; the scope is `chore`, `refactor`, `test`, `perf`, `build`, `ci`; the scope is
optional. For multi-point changes, use a summary line plus a body with optional. For multi-point changes, use a summary line plus a body with
bullet points and a verification block. bullet points and a verification block.
5. `git push origin main` 5. `git push origin main`
6. Report the commit hash and the verification summary 6. Report the commit hash and the verification summary
Exceptions — **do not** commit or push without asking: Exceptions; **do not** commit or push without asking:
- The change is exploratory / speculative (you're not sure the user wants - The change is exploratory / speculative (you're not sure the user wants
it kept) it kept)
@@ -44,7 +44,7 @@ Exceptions — **do not** commit or push without asking:
commit, or skipping hooks commit, or skipping hooks
Never commit secrets, keys, or credentials. If a commit fails or hooks Never commit secrets, keys, or credentials. If a commit fails or hooks
reject it, fix the issue and create a new commit — do not amend the reject it, fix the issue and create a new commit. Do not amend the
failed one. failed one.
Git identity is preconfigured (`glm-5.3-flash <glm-5.3-flash@alk.dev>`). Git identity is preconfigured (`glm-5.3-flash <glm-5.3-flash@alk.dev>`).
@@ -52,10 +52,10 @@ Do not change `git config`, skip hooks, or use `git commit -i`.
## Project Conventions (Rust / SSH adapter crate) ## Project Conventions (Rust / SSH adapter crate)
This is alkssh — the family's SSH adapter (family ADR-003): a thin This is alkssh, the family's SSH adapter (family ADR-003): a thin
wrapper around russh terminating the vanilla-ssh wire and bridging wrapper around russh terminating the vanilla-ssh wire and bridging
to/from alkcall channels. The SSH counterpart of alkhttp's adapter to/from alkcall channels. It is the SSH counterpart of alkhttp's adapter
role — the vanilla-ssh front door for vanilla clients. No binary until role, the vanilla-ssh front door for vanilla clients. No binary until
an assembly needs one; downstream apps embed the crate and bring their an assembly needs one; downstream apps embed the crate and bring their
own deployment. The conventions below apply to all work in `src/` and own deployment. The conventions below apply to all work in `src/` and
`tests/`. They mirror `.opencode/agents/implementation-specialist.md` `tests/`. They mirror `.opencode/agents/implementation-specialist.md`
@@ -67,93 +67,108 @@ session, not just spawned implementation agents.
expected on public API. Inline `//` comments only when the user asks expected on public API. Inline `//` comments only when the user asks
or when a non-obvious safety/correctness constraint would otherwise or when a non-obvious safety/correctness constraint would otherwise
be missed (e.g., "the decline path must run before any per-channel be missed (e.g., "the decline path must run before any per-channel
state is allocated — a leaked channel alloc is a capability leak"). state is allocated; a leaked channel alloc is a capability leak").
2. **Error handling** — `thiserror` for library error types. No panics 2. **Error handling**: `thiserror` for library error types. No panics
in library code. No `unwrap()` or `expect()` outside tests. If you in library code. No `unwrap()` or `expect()` outside tests. If you
reach for `unwrap`, the error path wasn't specified — stop and decide reach for `unwrap`, the error path wasn't specified; stop and decide
what should actually happen. For poisoned `RwLock`/`Mutex`, use what should actually happen. For poisoned `RwLock`/`Mutex`, use
`unwrap_or_else(|e| e.into_inner())` so a panic in one operation does `unwrap_or_else(|e| e.into_inner())` so a panic in one operation does
not cascade to other operations. not cascade to other operations.
3. **`tokio` is the async runtime** — all I/O is async. SSH sessions are 3. **`tokio` is the async runtime**: all I/O is async. SSH sessions are
long-lived streaming conversations; do not introduce blocking I/O on long-lived streaming conversations; do not introduce blocking I/O on
the async path. Use `tokio::sync` primitives (`oneshot`, `mpsc`) for the async path. Use `tokio::sync` primitives (`oneshot`, `mpsc`) for
correlation; `parking_lot` for short-held internal locks. correlation; `parking_lot` for short-held internal locks.
4. **russh owns the SSH wire** — no in-family SSH framing (family 4. **russh owns the SSH wire**: no in-family SSH framing (family
ADR-003 decision 1). All SSH protocol framing, key exchange, and ADR-003 decision 1). All SSH protocol framing, key exchange, and
channel multiplexing come from russh; the crate's substance is the channel multiplexing come from russh; the crate's substance is the
glue to/from alkcall and the channel-type mappings. Third-party- glue to/from alkcall and the channel-type mappings. Third-party-
upstream posture: file asks to russh upstream; never silently absorb upstream posture: file asks to russh upstream; never silently absorb
a workaround. a workaround.
5. **Fail-closed capability features (the binding constraint)** — every 5. **Fail-closed capability features (the binding constraint)**: every
channel-type mapping is a cargo feature. A session opening an channel-type mapping is a cargo feature. A session opening an
unfeatured or unimplemented channel type is declined with a clean unfeatured or unimplemented channel type is declined with a clean
error — never a half-capability, never a silent fallback (family error: never a half-capability, never a silent fallback (family
ADR-003 decision 3). A default build (no features) has no ADR-003 decision 3). A default build (no features) has no
capabilities; a git-only build cannot serve a shell, tunnels, or capabilities; a git-only build cannot serve a shell, tunnels, or
socks. This is alkforge's ssh endpoint shape. Every disabled socks. This is alkforge's ssh endpoint shape. Every disabled
feature's decline path is contract territory and gets tested too — feature's decline path is contract territory and gets tested too;
feature-gated code that is never exercised in a given build rots feature-gated code that is never exercised in a given build rots
silently otherwise. silently otherwise.
6. **Key reuse, no env vars** — keys reuse the alknet `main` key model 6. **Key reuse, no env vars**: keys reuse the alknet `main` key model
(`russh::keys`, authorized-key/KeySource handling — see (`russh::keys`, authorized-key/KeySource handling; see
`/workspace/@alkdev/alknet-main/crates/alknet-core/src/auth/`) via `/workspace/@alkdev/alknet-main/crates/alknet-core/src/auth/`) via
alkvault-backed sources — keys held encrypted at rest, decrypted at alkvault-backed sources: keys held encrypted at rest, decrypted at
use (the vault is a secret store on this path, not a key generator) use (the vault is a secret store on this path, not a key generator)
(family ADR-003 decision 2). No secret (family ADR-003 decision 2). No secret
material on the wire, in plaintext at rest, or in env vars — the material on the wire, in plaintext at rest, or in env vars; this is
standing family security posture (family ADR-002 decision 5). the standing family security posture (family ADR-002 decision 5).
7. **Visible-surface = authorized-surface** — every channel type and 7. **Visible-surface = authorized-surface**: every channel type and
request exposed on the wire is checked against alkcall's request exposed on the wire is checked against alkcall's
`AccessControl::check(peer_identity)`; ops with `AccessControl::check(peer_identity)`; ops with
`Visibility::Internal` are never wire-callable. There is no `Visibility::Internal` are never wire-callable. There is no
unauthenticated capability; the fail-closed feature declines are part unauthenticated capability; the fail-closed feature declines are part
of this invariant. This is a spec-level invariant. of this invariant. This is a spec-level invariant.
8. **Wire formats are stable one-way doors** — the `alk/ssh` ALPN pin is 8. **Wire formats are stable one-way doors**: the `alk/ssh` ALPN pin is
its own ADR (ledger rule 1, family ADR-003 decision 4) and, once its own ADR (ledger rule 1, family ADR-003 decision 4) and, once
published, does not change. The russh version pin is a one-way door published, does not change. The russh version pin is a one-way door
in practice (SSH key exchange/kex algorithm negotiation crosses in practice (SSH key exchange/kex algorithm negotiation crosses
crate boundaries); bumps are deliberate, tested, and recorded. crate boundaries); bumps are deliberate, tested, and recorded.
Protocol capability advertisement is honest: never advertise what we Protocol capability advertisement is honest: never advertise what we
don't serve — an unfeatured channel type is declined, not advertised. don't serve: an unfeatured channel type is declined, not advertised.
9. **Bounded resources** — every session carries wall-clock, size, and 9. **Bounded resources**: every session carries wall-clock, size, and
channel limits (max concurrent channels, max channel window, session channel limits (max concurrent channels, max channel window, session
timeout, auth attempt limits). SSH servers are internet-facing; timeout, auth attempt limits). SSH servers are internet-facing;
unbounded loops and unbounded buffers are bugs. unbounded loops and unbounded buffers are bugs.
10. **License hygiene** — this project is MIT OR Apache-2.0. russh is 10. **License hygiene**: this project is MIT OR Apache-2.0. russh is
MIT/Apache-2.0-compatible; check before adopting any new dependency, MIT/Apache-2.0-compatible; check before adopting any new dependency,
and never copy or derive code from incompatible-licensed reference and never copy or derive code from incompatible-licensed reference
projects. Reference code is for reading and facts, never copying. projects. Reference code is for reading and facts, never copying.
11. **Naming** — Rust standard: `snake_case` for functions/variables/ 11. **Naming**: Rust standard: `snake_case` for functions/variables/
modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for
constants. constants.
12. **Module structure** — one module per file under `src/`, re-exported 12. **Module structure**: one module per file under `src/`, re-exported
from the crate root (the alktty crate-root pattern). Public API from the crate root (the alktty crate-root pattern). Public API
surface is `lib.rs` re-exports. The mapping table surface is `lib.rs` re-exports. The mapping table
(channel-type → family protocol) is the crate's spine; each mapping (channel-type → family protocol) is the crate's spine; each mapping
lives behind its feature and speaks only its family protocol's lives behind its feature and speaks only its family protocol's
types. types.
13. **Composability boundary** — alkssh is a door, not a backend: it 13. **Composability boundary**: alkssh is a door, not a backend: it
terminates vanilla ssh and forwards mapped channel payloads to terminates vanilla ssh and forwards mapped channel payloads to
family protocol halves (alktty/alktunnels/alksocks/alkgit). It family protocol halves (alktty/alktunnels/alksocks/alkgit). It
depends on alkcall types at the wire layer and the mapped crates' depends on alkcall types at the wire layer and the mapped crates'
session/client surfaces, and carries no deployment policy — a session/client surfaces, and carries no deployment policy; a
downstream app (alkforge) embeds it, enables the features it wants, downstream app (alkforge) embeds it, enables the features it wants,
and brings identity/ACL configuration. No feature implies another; and brings identity/ACL configuration. No feature implies another;
the feature graph is independent and flat until architecture says the feature graph is independent and flat until architecture says
otherwise. otherwise.
14. **No em dashes in prose.** Do not use the em dash (U+2014) in source
doc comments, markdown docs (`docs/`, `README.md`, `CHANGELOG.md`,
`AGENTS.md`), agent prompts under `.opencode/agents/`, or commit
messages. Restructure instead: split the sentence, use parentheses,
commas, a colon, or a semicolon, whichever reads
naturally, and vary the choice rather than converting every instance
the same way. A spaced hyphen (` - `) is not an acceptable
substitute; it reads as a different but equally recognizable writing
tell. The same rule covers en dashes used as clause separators,
though en dashes inside numeric ranges keep their normal meaning.
Hyphens in compound words (`fail-closed`, `client-side`) are
untouched. In test fixtures and scripts the rule applies to prose-y
content only: comment lines are rewritten too, while fixture data,
wire examples, and tool transcripts survive verbatim.
## Verification Commands ## Verification Commands
Run these before committing. All must pass. Run these before committing. All must pass.
@@ -164,6 +179,9 @@ cargo clippy --all-targets -- -D warnings
cargo fmt --check cargo fmt --check
cargo doc --no-deps # if docs changed cargo doc --no-deps # if docs changed
cargo publish --dry-run --allow-dirty # before a release cargo publish --dry-run --allow-dirty # before a release
grep -rn '—' src/ docs/ README.md CHANGELOG.md AGENTS.md \
.opencode/agents/ tests/ scripts/ # must be zero hits;
# see convention 14
scripts/matrix.sh # the decline-matrix runner: the full suite per scripts/matrix.sh # the decline-matrix runner: the full suite per
# feature build (none / git / session / tunnel / # feature build (none / git / session / tunnel /
# sftp / --all-features), clippy ×2, fmt, doc # sftp / --all-features), clippy ×2, fmt, doc
@@ -173,7 +191,7 @@ cargo test --manifest-path fuzz/shared/Cargo.toml # the fuzz corpus gate: the
If feature flags are added, also run `cargo test --all-features` and If feature flags are added, also run `cargo test --all-features` and
`cargo clippy --all-features --all-targets -- -D warnings`. Once the `cargo clippy --all-features --all-targets -- -D warnings`. Once the
feature graph lands, each disabled feature's decline path gets a test — feature graph lands, each disabled feature's decline path gets a test;
the fail-closed invariant is contract territory on both sides of every the fail-closed invariant is contract territory on both sides of every
feature. feature.
@@ -187,53 +205,53 @@ Any file under `tasks/` is a taskgraph task. Frontmatter is required
isolated|component|phase|project`, `level: isolated|component|phase|project`, `level:
planning|decomposition|implementation|review|research`. A file without planning|decomposition|implementation|review|research`. A file without
frontmatter is silently dropped from the graph (validate does not flag frontmatter is silently dropped from the graph (validate does not flag
it) — prose-only trackers belong under `docs/`. Run `taskgraph it); prose-only trackers belong under `docs/`. Run `taskgraph
validate` after writing or editing anything under `tasks/`; full field validate` after writing or editing anything under `tasks/`; full field
semantics live in `docs/sdd_process.md` §"Task File Format" / semantics live in `docs/sdd_process.md` §"Task File Format" /
§"Categorical Estimates". §"Categorical Estimates".
## Lifecycle Status ## Lifecycle Status
The project is in **SDD phase 0** (exploration; docs only — no The project is in **SDD phase 0** (exploration; docs only, no
architecture committed, no implementation). Family ADR-003 (2026-10-25, architecture committed, no implementation). Family ADR-003 (2026-10-25,
Accepted) records the structural decision already made: russh-wrapped Accepted) records the structural decision already made: russh-wrapped
adapter, no in-family SSH framing, fail-closed capability features, adapter, no in-family SSH framing, fail-closed capability features,
alknet-main key model via alkvault-backed sources; OQ-08's ordering alknet-main key model via alkvault-backed sources; OQ-08's ordering
question is resolved (alkssh leads alkforge's phase 0) and OQ-08's question is resolved (alkssh leads alkforge's phase 0) and OQ-08's
remaining scope — the channel-type → family-protocol mapping table remaining scope (the channel-type → family-protocol mapping table
details, the auth design tying russh's authorized-key handling to details, the auth design tying russh's authorized-key handling to
alkcall auth, and the `alk/ssh` ALPN pin (ledger rule 1) — is this alkcall auth, and the `alk/ssh` ALPN pin, ledger rule 1) is this
repo's phase 0 work. Phase 0 output lands in `docs/research/` (the repo's phase 0 work. Phase 0 output lands in `docs/research/` (the
phase 0 document is the current work item); `docs/architecture/` + phase 0 document is the current work item); `docs/architecture/` +
ADR-001 (the `alk/ssh` ALPN pin) come with phase 1. POC code never ADR-001 (the `alk/ssh` ALPN pin) come with phase 1. POC code never
merges to main — two modes: branch mode (POC builds on repo code; git merges to main; two modes exist: branch mode (POC builds on repo code; git
branch, findings merged into research docs, branch dropped) or branch, findings merged into research docs, branch dropped) or
standalone mode (POC independent of repo code; scratch project at standalone mode (POC independent of repo code; scratch project at
`/workspace/<poc-name>`). POCs have relaxed constraints `/workspace/<poc-name>`). POCs have relaxed constraints
(comments/unwrap acceptable) — they are exploration tools, not (comments/unwrap acceptable) since they are exploration tools, not
production code. production code.
## Architecture Context ## Architecture Context
- `docs/sdd_process.md` — the SDD process (phases, roles, task format) - `docs/sdd_process.md`: the SDD process (phases, roles, task format)
- `docs/research/` — phase 0 research (current source of truth for - `docs/research/`: phase 0 research (current source of truth for
design direction): design direction):
- `vision.md` — vision, guiding principles, non-goals, the phase 0 - `vision.md`: vision, guiding principles, non-goals, the phase 0
scope questions scope questions
- `russh.md` — russh crate inventory (server/client Handler, Config - `russh.md`: russh crate inventory (server/client Handler, Config
knobs, keys module) and pin considerations (RC-pinned dep tree, knobs, keys module) and pin considerations (RC-pinned dep tree,
MSRV, license) — no alknet-derived bias; the pin is verified MSRV, license); no alknet-derived bias. The pin is verified
against current russh at architecture time against current russh at architecture time
- `auth-bridge.md` — russh auth ↔ alkcall `IdentityProvider` mapping - `auth-bridge.md`: russh auth ↔ alkcall `IdentityProvider` mapping
(server: verified-key fingerprint → `resolve_from_fingerprint`; (server: verified-key fingerprint → `resolve_from_fingerprint`;
client: alkvault-backed key sources — held keys encrypted at rest, client: alkvault-backed key sources, held keys encrypted at rest,
at-rest/bearer-token-class storage, not key derivation) at-rest/bearer-token-class storage, not key derivation)
- `mapping-table.md` — the crate's spine: SSH channel type / request - `mapping-table.md`: the crate's spine: SSH channel type / request
→ family protocol seam + cargo feature; the alktunnels → family protocol seam + cargo feature; the alktunnels
absence note; the `alk/ssh` ALPN topology question absence note; the `alk/ssh` ALPN topology question
- `pocs.md` — POC-1..3 plan (auth bridge, client-side at-rest key - `pocs.md`: POC-1..3 plan (auth bridge, client-side at-rest key
handling, mapping bake-off) gating phase 0 completion handling, mapping bake-off) gating phase 0 completion
- family docs: `/workspace/@alkdev/alkfamily/docs/` — - family docs: `/workspace/@alkdev/alkfamily/docs/`;
`architecture/decisions/003-alkssh-russh-adapter.md` is the structural `architecture/decisions/003-alkssh-russh-adapter.md` is the structural
decision this repo builds from (and `002-alkforge-gitea-replacement.md` decision this repo builds from (and `002-alkforge-gitea-replacement.md`
explains why: alkforge's git door, fail-closed posture); explains why: alkforge's git door, fail-closed posture);
@@ -249,8 +267,8 @@ production code.
analog for the adapter shape. analog for the adapter shape.
- alknet `main` key model reference: - alknet `main` key model reference:
`/workspace/@alkdev/alknet-main/crates/alknet-core/src/auth/` `/workspace/@alkdev/alknet-main/crates/alknet-core/src/auth/`
(russh 0.49, `keys.rs` / `server_auth.rs` / `identity.rs`) — the (russh 0.49, `keys.rs` / `server_auth.rs` / `identity.rs`): the
authorized-key/KeySource construction alkssh reuses; a reading aid authorized-key/KeySource construction alkssh reuses; a reading aid
and correctness reference, not a path dependency. and correctness reference, not a path dependency.
- russh: pin per phase 0 research (the alknet pin — 0.49 — is a data - russh: pin per phase 0 research (the alknet pin, 0.49, is a data
point, not a decision). point, not a decision).
+103 -28
View File
@@ -4,68 +4,143 @@ All notable changes to this crate are documented here. The format is
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
this crate adheres to [Semantic Versioning](https://semver.org/). this crate adheres to [Semantic Versioning](https://semver.org/).
## [0.2.0] - 2026-11-04
The sibling-adoption release: the three alkssh-filed upstream reviews
resolved across the family, and alkssh adopts the results. Two
dependency requirement bumps (the minor bump, not a patch: dep-req
changes are semver-significant for consumers) plus the review-003 W2
adoption wave. No alkssh-side wire-format change, no public API
change apart from what the dep bumps ripple through.
### Changed
- **alkvault 0.1 → 0.2** (breaking req): `VaultServiceHandle::decrypt`
returns `Zeroizing<String>` (alkvault review 001, consumer input
from alkssh; resolution paths 1+2). The adapter seam's own
`Zeroizing` wrap in `decrypt_and_decode` is **dropped** (it would be
a dead double wrap): the pre-wrap unfenced plaintext window ADR-005
decision 3 recorded is closed upstream; the vault fences the
plaintext from its return onward, on every path including its UTF-8
error path, and `rotate` never holds a bare plaintext either.
`decode_key_arm`'s signature is unchanged (it kept riding
`&Zeroizing<String>`; the posture now proven by the vault's return
type instead of the adapter seam).
- **alktunnels 0.4.1 → 0.5.0** (breaking req, additive API): the
review-003 W2 adoption: the forwarded serving surface
(`-R` leg's door-side arrival half) now registers
**`register_tunnel_forwarded_openable_with_establisher`** with a
`ForwardedGate` capturing the door's `ListenRouter`: a stale/missing
`listen` token fails the open **typed at the call plane**
(`channel:open_failed`, `details.reason == "unknown_resource"`)
*before any channel is born*, closing the phantom-channel shape
(call-plane success, then handler-side decline) ADR-008 Amendment 2
targets; a malformed arrival is likewise typed (`handler_error`)
instead of success-then-silent-birth-teardown. The handler-side
correlation re-lookup keeps the residual-race + cap-admission
teardown-at-birth fallback (the base shape's posture, retained).
The wire delta is empty (additive registration only); the ACL
(`tunnel:forwarded`) still runs before the establisher (now
double-pinned by the unscoped-arrival row, which uses a genuinely
minted token).
- **alktty 0.8.1 → 0.8.2** (patch): the PTY exit-signal-fidelity fix
(alktty review 004, consumer input from alkssh); the requirement
resolves to 0.8.2 automatically; the pin text refreshed. **No
alkssh contract change**: the negative-code convention (OQ-10's
derivation contract) is consumed verbatim, so PTY-row signal
deaths now report `exit-signal(name)` through the door exactly as
pipe-mode already did; OQ-10's upstream constraint (the
portable-pty flattening) is retired.
### Documentation
- ADR-005 decision 3 amended (the gap is closed upstream; the
adapter's obligation shrinks to consuming the zeroized value in one
pass), the `Negative` consequence amended, OQ-07 and OQ-10 amended +
the two upstream watch items closed, `mapping.md`'s `forwarded-tcpip`
row (the gate shape) and derivation-contract rule 5 (the PTY
constraint retired) updated, `client.md`'s OQ-07 anchor updated.
### Fixed
- The test doubles' fidelity: `tests/tunnel_mapping.rs`'s harness
opener now maps the wire refusal's `details.reason` back into the
`TunnelEstablishError` taxonomy (the real opener's table) instead of
swallowing every call-plane refusal into `HandlerError`; the typed
`unknown_resource` survives the client side, which is exactly what
the new gate rows pin.
### New rows
- `a_correlation_miss_fails_the_open_typed_before_any_channel` (the
gate miss: typed refusal, no phantom channel, no acceptor row),
`a_gate_pass_arrival_serves_the_route_unchanged` (the gate-pass
posture: the serving flow is the unchanged shape), and the source
unit pin `the_pump_residual_miss_teardown_at_births_the_fabric_half`
(the handler-side residual miss stays teardown-at-birth).
## [0.1.0] - 2026-10-30 ## [0.1.0] - 2026-10-30
The founding entry — the crate's first release. `alkssh` is the alk The founding entry: the crate's first release. `alkssh` is the alk
family's SSH adapter (family ADR-003): a thin wrapper around russh family's SSH adapter (family ADR-003): a thin wrapper around russh
0.65 terminating the vanilla-ssh wire and bridging to/from alkcall 0.65 terminating the vanilla-ssh wire and bridging to/from alkcall
channels — the two-direction door for vanilla clients. The wire is channels, the two-direction door for vanilla clients. The wire is
russh's; the crate's substance is the channel-type mappings russh's; the crate's substance is the channel-type mappings
(session/pty → alktty, direct/forwarded-tcpip → alktunnels, git exec → (session/pty → alktty, direct/forwarded-tcpip → alktunnels, git exec →
alkgit), each behind its cargo feature in a flat, independent graph alkgit), each behind its cargo feature in a flat, independent graph
(ADR-003): **a default build has no capabilities and serves nothing (ADR-003): **a default build has no capabilities and serves nothing
but clean declines** — every unfeatured or unimplemented request is but clean declines**. Every unfeatured or unimplemented request is
declined fail-closed, never half-served or silently fallen back, and declined fail-closed, never half-served or silently fallen back, and
never advertised. Identity is the family's: a verified key's never advertised. Identity is the family's: a verified key's
fingerprint → alkcall `IdentityProvider`; keys the crate holds come fingerprint → alkcall `IdentityProvider`; keys the crate holds come
from alkvault-backed sources, encrypted at rest, decrypted at use, from alkvault-backed sources, encrypted at rest, decrypted at use,
never env vars (ADR-005). The crate is a door, not a backend — no never env vars (ADR-005). The crate is a door, not a backend: no
binary, no deployment policy; a downstream app embeds it, enables binary, no deployment policy; a downstream app embeds it, enables
features, and brings identity/ACL configuration. features, and brings identity/ACL configuration.
### Added ### Added
**Phase 0/1 — research and architecture** (the contract the **Phase 0/1, research and architecture** (the contract the
implementation lands): implementation lands):
- SDD process, phase 0 research set (`docs/research/`), and the three - SDD process, phase 0 research set (`docs/research/`), and the three
passed POCs (auth bridge, vault keys, mapping bake-off; findings in passed POCs (auth bridge, vault keys, mapping bake-off; findings in
`poc-1..3` + `reverse-runner.md`; POC source never merged). `poc-1..3` + `reverse-runner.md`; POC source never merged).
- Phase 1 architecture under `docs/architecture/`: the spec docs and - Phase 1 architecture under `docs/architecture/`: the spec docs and
ADRs 001–006 (Accepted) — the `alk/ssh` ALPN pin; the russh 0.65.0 ADRs 001–006 (Accepted): the `alk/ssh` ALPN pin; the russh 0.65.0
pin (aws-lc-rs, no compression, rsa off); the capability feature pin (aws-lc-rs, no compression, rsa off); the capability feature
graph; auth dispositions; vault-key custody; the mapping/bridge/ graph; auth dispositions; vault-key custody; the mapping/bridge/
dispatch decisions. dispatch decisions.
**Wave 1 — foundations:** **Wave 1, foundations:**
- The manifest: the russh 0.65 pin (`aws-lc-rs`, default features - The manifest: the russh 0.65 pin (`aws-lc-rs`, default features
off), the flat feature graph (`session` / `git` / `tunnel` / off), the flat feature graph (`session` / `git` / `tunnel` /
`sftp`, `default = []`). `sftp`, `default = []`).
- The fail-closed error taxonomy (`thiserror`): `AdapterError`, - The fail-closed error taxonomy (`thiserror`): `AdapterError`,
`DialError`, `KeySourceError`, `OpError`, `OpenDeclineReason` — `DialError`, `KeySourceError`, `OpError`, `OpenDeclineReason`:
declines are dispositions, never panics; no `unwrap` outside tests. declines are dispositions, never panics; no `unwrap` outside tests.
- Vault-backed key custody in code: the `KeySource` / - Vault-backed key custody in code: the `KeySource` /
`HostKeySource` seams and the ADR-005 implementations `HostKeySource` seams and the ADR-005 implementations
(`VaultKeySource`, `VaultHostKeySource`) — decrypt-at-use / (`VaultKeySource`, `VaultHostKeySource`): decrypt-at-use /
decrypt-at-construction, the `Zeroizing` seam, one decrypt-at-construction, the `Zeroizing` seam, one
`decode_secret_key` conversion. `decode_secret_key` conversion.
- The `HostKeyPolicy` seam (OQ-05, policy-blind) with the fail-closed - The `HostKeyPolicy` seam (OQ-05, policy-blind) with the fail-closed
default `RejectAll` — no accept-all built-in exists in the crate. default `RejectAll`; no accept-all built-in exists in the crate.
- The contract-invariant fingerprint computation (the crate's single - The contract-invariant fingerprint computation (the crate's single
fingerprint path, the `SHA256:` format both the auth bridge and the fingerprint path, the `SHA256:` format both the auth bridge and the
host-key policy consume). host-key policy consume).
- The config surface: `ServeConfig`, `DialConfig`, `Limits` (the - The config surface: `ServeConfig`, `DialConfig`, `Limits` (the
bounded-resources budget model — wall clock always set, channel bounded-resources budget model: wall clock always set, channel
caps, keepalive posture) — unset fields decline at request time caps, keepalive posture); unset fields decline at request time
(OQ-08), construction never validates. (OQ-08), construction never validates.
**Wave 2 — the server stack:** **Wave 2, the server stack:**
- `SshAdapter` — the mount point: `SshAdapter::new(serve)` builds the - `SshAdapter`, the mount point: `SshAdapter::new(serve)` builds the
russh server from the config (fail-closed on key-source failure); russh server from the config (fail-closed on key-source failure);
`adapter.handle(stream)` serves one connection. `adapter.handle(stream)` serves one connection.
- The auth bridge: russh auth hooks → ADR-004 dispositions — - The auth bridge: russh auth hooks → ADR-004 dispositions:
verified-key fingerprint → `IdentityProvider::resolve_from_fingerprint`, verified-key fingerprint → `IdentityProvider::resolve_from_fingerprint`,
fail-closed on every path; bounded auth attempts. fail-closed on every path; bounded auth attempts.
- Parse-only dispatch + the fail-closed decline core: every unroutable - Parse-only dispatch + the fail-closed decline core: every unroutable
@@ -78,12 +153,12 @@ implementation lands):
caps (refuse-never-queue) and listener caps. caps (refuse-never-queue) and listener caps.
- The in-process loopback harness (the contract-test vehicle). - The in-process loopback harness (the contract-test vehicle).
**Wave 3 — the server mappings (the spine):** **Wave 3, the server mappings (the spine):**
- Per-feature seam handles (`SessionSeam`, `GitSeam`, - Per-feature seam handles (`SessionSeam`, `GitSeam`,
`TunnelListenSeam`): unset seam → decline even with the feature on. `TunnelListenSeam`): unset seam → decline even with the feature on.
- Decline-path tracing (`tracing` events on every decline class). - Decline-path tracing (`tracing` events on every decline class).
- The git mapping (exec-only, ADR-006): the full exec grammar — - The git mapping (exec-only, ADR-006): the full exec grammar,
`git-{upload,receive}-pack '/<repo>'` parsed and dispatched to `git-{upload,receive}-pack '/<repo>'` parsed and dispatched to
alkgit; a subsystem request can never carry the repository and alkgit; a subsystem request can never carry the repository and
declines; `1` is honestly reported for push `ng`. declines; `1` is honestly reported for push `ng`.
@@ -92,45 +167,45 @@ implementation lands):
ttymodes JSON, the exit-chunk → SSH exit derivation (rules 1–6), ttymodes JSON, the exit-chunk → SSH exit derivation (rules 1–6),
control rows (window-change, signal) on a bounded per-channel control rows (window-change, signal) on a bounded per-channel
surface. surface.
- Tunnel leg 1: the route-1 bridge — `direct-tcpip` / - Tunnel leg 1, the route-1 bridge: `direct-tcpip` /
`direct-streamlocal` opens ride the assembly's `TunnelSession` over `direct-streamlocal` opens ride the assembly's `TunnelSession` over
the family fabric; fabric failure is a clean `ConnectFailed` reject. the family fabric; fabric failure is a clean `ConnectFailed` reject.
- Tunnel leg 2: the `-R` listen flow — `tcpip_forward` / - Tunnel leg 2, the `-R` listen flow: `tcpip_forward` /
`streamlocal_forward` dial the fabric, arrivals correlate through `streamlocal_forward` dial the fabric, arrivals correlate through
the token router (miss → teardown-at-birth), the data leg opens the token router (miss → teardown-at-birth), the data leg opens
`forwarded-tcpip`/`forwarded-streamlocal` back toward the client, `forwarded-tcpip`/`forwarded-streamlocal` back toward the client,
port-0 mints echo the actually-bound port. port-0 mints echo the actually-bound port.
**Wave 4 — the client direction:** **Wave 4, the client direction:**
- The dial surface: `dial(DialConfig)` — key load (decrypt-at-dial), - The dial surface: `dial(DialConfig)`: key load (decrypt-at-dial),
bounded russh client config, the host-key policy call, exactly one bounded russh client config, the host-key policy call, exactly one
publickey attempt; `SshConnection` the handle. publickey attempt; `SshConnection` the handle.
- The op rows: `exec` / `pty_exec` / `shell` (the alktty chunk plane), - The op rows: `exec` / `pty_exec` / `shell` (the alktty chunk plane),
`tunnel_direct` / `tunnel_direct_unix` (byte duplex), `tunnel_direct` / `tunnel_direct_unix` (byte duplex),
`git_service` (pack duplex + consumed exit) — plus the `sftp` `git_service` (pack duplex + consumed exit), plus the `sftp`
decline row (always declines, every build). decline row (always declines, every build).
- The `-R` leg: `reverse_listen` / `reverse_listen_unix` + the - The `-R` leg: `reverse_listen` / `reverse_listen_unix` + the
`AcceptorRegistry` (refuse-never-replace) and the acceptor traits — `AcceptorRegistry` (refuse-never-replace) and the acceptor traits:
the hub asks the vanilla server to listen and serves the accepted the hub asks the vanilla server to listen and serves the accepted
channels. channels.
- Lifecycle primitives (OQ-12): explicitly cancellable listener - Lifecycle primitives (OQ-12): explicitly cancellable listener
handles, clean `close()` (`Disconnect::ByApplication`) — drop is handles, clean `close()` (`Disconnect::ByApplication`); drop is
never the clean path at this pin; no reconnect/restore policy (the never the clean path at this pin; no reconnect/restore policy (the
crate ships primitives, the assembly composes). crate ships primitives, the assembly composes).
- Hardening riders: the git row's status-before-close by construction, - Hardening riders: the git row's status-before-close by construction,
the channel-not-held guard wire rows, tty control-row coverage, the the channel-not-held guard wire rows, tty control-row coverage, the
`ListenRouter` token-collision pre-check. `ListenRouter` token-collision pre-check.
**Wave 5 — validation and release:** **Wave 5, validation and release:**
- The decline-matrix contract suite: every mapping-decline row × every - The decline-matrix contract suite: every mapping-decline row × every
build it can appear in × both directions (the matrix runner build it can appear in × both directions (the matrix runner
`scripts/matrix.sh` — one command over all feature builds). `scripts/matrix.sh`, one command over all feature builds).
- Real-tool e2e both directions: OpenSSH (pty/shell/exec, `-L`, `-R`, - Real-tool e2e both directions: OpenSSH (pty/shell/exec, `-L`, `-R`,
`-D` + curl), git clone and push through the door, and a real `-D` + curl), git clone and push through the door, and a real
`sshd` against the client direction (exec, direct-tcpip dial, `-R` `sshd` against the client direction (exec, direct-tcpip dial, `-R`
listen + acceptor) — check-gated, skipped rows carry reasons. listen + acceptor), check-gated; skipped rows carry reasons.
- The `fuzz/` sidecar: the ingress surfaces (exec parse grammar, - The `fuzz/` sidecar: the ingress surfaces (exec parse grammar,
fingerprint format, key decode, `TerminalParamsWire`) with the fingerprint format, key decode, `TerminalParamsWire`) with the
committed-seed corpus gate (`cargo test --manifest-path committed-seed corpus gate (`cargo test --manifest-path
@@ -141,7 +216,7 @@ implementation lands):
### Notes ### Notes
- `sftp` is a feature of decline: enabling it adds no capability — - `sftp` is a feature of decline: enabling it adds no capability;
the `sftp` subsystem request still declines in every build until the `sftp` subsystem request still declines in every build until
the alkfs mapping lands (OQ-13; the decline stays contract the alkfs mapping lands (OQ-13; the decline stays contract
territory). territory).
Generated
+7 -7
View File
@@ -109,7 +109,7 @@ dependencies = [
[[package]] [[package]]
name = "alkssh" name = "alkssh"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"alkcall", "alkcall",
"alkgit", "alkgit",
@@ -131,9 +131,9 @@ dependencies = [
[[package]] [[package]]
name = "alktty" name = "alktty"
version = "0.8.1" version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2265167842b7d3ea09bc235f48450d3dcf22049a2ca71f8155de00acbf97c226" checksum = "9cab9b0d3d6c1bdef2622799e3b95bb6063720153a124a57f4c60f95f3d42ea8"
dependencies = [ dependencies = [
"alkcall", "alkcall",
"async-trait", "async-trait",
@@ -153,9 +153,9 @@ dependencies = [
[[package]] [[package]]
name = "alktunnels" name = "alktunnels"
version = "0.4.1" version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee56da894f2d02120d3602df32c7f6a9db7f930a401df66d40b632cd9767af41" checksum = "198d3aa766889cf5cc9b764319f9aa75e21234d623021893c26675f7c4276242"
dependencies = [ dependencies = [
"alkcall", "alkcall",
"bytes", "bytes",
@@ -169,9 +169,9 @@ dependencies = [
[[package]] [[package]]
name = "alkvault" name = "alkvault"
version = "0.1.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52e032b074f7fea71ea15528994c5b3a6503c082c519045bb3b0519281291918" checksum = "b2522ded10a506770779e72063422057bb63f1b75457c1742a3362072adfb1c4"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"base64", "base64",
+46 -34
View File
@@ -1,6 +1,6 @@
# Single-crate protocol/door crate (the alktty template). No workspace: # Single-crate protocol/door crate (the alktty template). No workspace:
# the composition story is deferred — alkssh's substance is the mappings # the composition story is deferred: alkssh's substance is the mappings
# (family ADR-003). The `fuzz/` sidecar is its own workspace (excluded — # (family ADR-003). The `fuzz/` sidecar is its own workspace (excluded:
# it pins its own nightly toolchain and never rides the root's). # it pins its own nightly toolchain and never rides the root's).
[workspace] [workspace]
members = ["."] members = ["."]
@@ -8,18 +8,18 @@ exclude = ["fuzz"]
[package] [package]
name = "alkssh" name = "alkssh"
version = "0.1.0" version = "0.2.0"
edition = "2024" edition = "2024"
rust-version = "1.89" rust-version = "1.89"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
description = "SSH adapter for the alk family: russh-wrapped vanilla-ssh termination bridged to/from alkcall channels — session/pty, tunnels, git exec — with every capability mapping a fail-closed cargo feature (no features: no capabilities)" description = "SSH adapter for the alk family: russh-wrapped vanilla-ssh termination bridged to/from alkcall channels (session/pty, tunnels, git exec), with every capability mapping a fail-closed cargo feature (no features: no capabilities)"
repository = "https://git.alk.dev/alkdev/alkssh" repository = "https://git.alk.dev/alkdev/alkssh"
keywords = ["ssh", "russh", "protocol", "alkcall", "network"] keywords = ["ssh", "russh", "protocol", "alkcall", "network"]
categories = ["network-programming", "asynchronous"] categories = ["network-programming", "asynchronous"]
# The package policy (the sibling crates' convention): the published crate # The package policy (the sibling crates' convention): the published crate
# ships the architecture docs (the authoritative contract) but not the # ships the architecture docs (the authoritative contract) but not the
# process surface — reviews, research, plans, task files, the working # process surface: reviews, research, plans, task files, the working
# agents' config, AGENTS.md — and not the `fuzz/` sidecar (its own # agents' config, AGENTS.md; and not the `fuzz/` sidecar (its own
# workspace; never a root dep, never a shipped surface). # workspace; never a root dep, never a shipped surface).
exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/plans/", "docs/research/", "docs/sdd_process.md", "tasks/", "fuzz/"] exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/plans/", "docs/research/", "docs/sdd_process.md", "tasks/", "fuzz/"]
@@ -27,12 +27,12 @@ exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/plans/", "docs/rese
name = "alkssh" name = "alkssh"
[features] [features]
# ADR-003: flat and independent — no feature implies another; `default = []` # ADR-003: flat and independent, no feature implies another; `default = []`
# by design (a default build has no capabilities; every unfeatured open # by design (a default build has no capabilities; every unfeatured open
# declines fail-closed). The wave-2 decline core is the first `cfg` # declines fail-closed). The wave-2 decline core is the first `cfg`
# consumer; the wave-3 seam-config handles are the second (each optional # consumer; the wave-3 seam-config handles are the second (each optional
# sibling dep is gated feature-to-dep so a git-build never compiles # sibling dep is gated feature-to-dep so a git-build never compiles
# alktty and vice versa — the wave-1 manifest rule: per-feature sibling # alktty and vice versa, per the wave-1 manifest rule: per-feature sibling
# deps land with their first consuming wave, which for alktty/alkgit is # deps land with their first consuming wave, which for alktty/alkgit is
# the wave-3 config fields and for alktunnels `w3-tunnel-direct`, the # the wave-3 config fields and for alktunnels `w3-tunnel-direct`, the
# route-1 bridge). # route-1 bridge).
@@ -44,38 +44,50 @@ sftp = []
[dependencies] [dependencies]
# async-trait (the alkcall precedent) makes the injected handles # async-trait (the alkcall precedent) makes the injected handles
# dyn-compatible (`Arc<dyn ...>`) — the HostKeyPolicy dial handle needs # dyn-compatible (`Arc<dyn ...>`); the HostKeyPolicy dial handle needs
# that, and native AFIT traits are not object-safe at this toolchain. # that, and native AFIT traits are not object-safe at this toolchain.
async-trait = "0.1" async-trait = "0.1"
# ADR-002: aws-lc-rs only; default features off — no flate2, no rsa # ADR-002: aws-lc-rs only; default features off, meaning no flate2 and
# (ed25519-only; the tree stays RC-free except the ssh-key pin riding the # no rsa (ed25519-only; the tree stays RC-free except the ssh-key pin riding the
# russh keys path). Never enable russh's `des`/`dsa` features. # russh keys path). Never enable russh's `des`/`dsa` features.
russh = { version = "0.65", default-features = false, features = ["aws-lc-rs"] } russh = { version = "0.65", default-features = false, features = ["aws-lc-rs"] }
alkcall = "0.10" alkcall = "0.10"
alkvault = "0.1" # alkvault 0.2 (breaking req bump, 0.1 -> 0.2, 2026-11-04): `decrypt`
# returns `Zeroizing<String>` (alkvault review 001's resolution; the
# pre-wrap plaintext window ADR-005 decision 3 recorded is closed
# upstream); the adapter-side wrap is dropped. Pin re-verified against
# crates.io: MIT OR Apache-2.0.
alkvault = "0.2"
# Wave-3 seam-handle types (`config.rs`'s `SessionSeam`/`GitSeam`). # Wave-3 seam-handle types (`config.rs`'s `SessionSeam`/`GitSeam`).
# Pins verified against crates.io (2026-10-28): alktty 0.8.1, alkgit # Pins verified against crates.io (2026-11-04): alktty 0.8.2 (0.1.0's
# 0.1.0 — both MIT OR Apache-2.0, both rust_version 1.88 ≤ our 1.89, # 0.8.1 pin refreshed; 0.8.2 is the PTY exit-signal-fidelity fix,
# both over alkcall ^0.10 (shared with our `alkcall = "0.10"` → 0.10.1 # patch-level, API/wire unchanged; alkssh consumes negative exit
# resolution). alktty's `local` feature stays off — alkssh never # codes verbatim so the derivation contract needed no change), alkgit
# touches a real backend; the `local` shapes stay alktty-side. # 0.1.0: both MIT OR Apache-2.0, both rust_version ≤ our 1.89,
alktty = { version = "0.8.1", default-features = false, optional = true } # both over alkcall ^0.10 (shared with our `alkcall = "0.10"` →
# 0.10.1 resolution). alktty's `local` feature stays off; alkssh
# never touches a real backend; the `local` shapes stay alktty-side.
alktty = { version = "0.8.2", default-features = false, optional = true }
alkgit = { version = "0.1.0", default-features = false, optional = true } alkgit = { version = "0.1.0", default-features = false, optional = true }
# tunnel-only: the route-1 bridge's consumer surface (`TunnelSession:: # tunnel-only: the route-1 bridge's consumer surface (`TunnelSession::
# open_direct`/`take_halves`, `SubstrateAddr`/`Substrate`). Pin # open_direct`/`take_halves`, `SubstrateAddr`/`Substrate`) + the
# re-verified against crates.io at `w3-tunnel-direct` (2026-10-29): # forwarded serving surface's establisher shape
# 0.4.1 is `max_stable_version`/`newest_version`, MIT OR Apache-2.0, # (`register_tunnel_forwarded_openable_with_establisher` +
# rust_version 1.88 ≤ our 1.89, alkcall ^0.10.1 pinned exactly # `ForwardedGate`, the review-003 W2 adoption, ADR-008 Amendment 2's
# (single-versioned against our `alkcall = "0.10"`). The `local` # recommended shape). Pin re-verified against crates.io (2026-11-04):
# feature stays off — the dial seam is injected (`DialFn`); the real # 0.5.0 is newest_version, additive API only over 0.4.1 (the wire
# dial/backends belong to the assembly, and the tests hand-roll their # delta is empty, the base registration is byte-for-byte), MIT OR
# own dial/dial-fail seams (`tests/tunnel_mapping.rs`). # Apache-2.0, rust_version 1.88 ≤ our 1.89, alkcall ^0.10.1 pinned
alktunnels = { version = "0.4.1", default-features = false, optional = true } # exactly (single-versioned against our `alkcall = "0.10"`). The
# `local` feature stays off (the dial seam is injected `DialFn`);
# the real dial/backends belong to the assembly, and the tests
# hand-roll their own dial/dial-fail seams (`tests/tunnel_mapping.rs`).
alktunnels = { version = "0.5.0", default-features = false, optional = true }
# session-only: the OQ-44 reserved ttymodes JSON (`TerminalParamsWire.modes`) # session-only: the OQ-44 reserved ttymodes JSON (`TerminalParamsWire.modes`)
# and the exit-chunk / negotiation-error-frame JSON reads in `tty_map.rs`. # and the exit-chunk / negotiation-error-frame JSON reads in `tty_map.rs`.
# Same pin alktty carries (crate resolution stays single-versioned). # Same pin alktty carries (crate resolution stays single-versioned).
serde_json = { version = "1", optional = true } serde_json = { version = "1", optional = true }
# session-only: the alktty chunk plane is `bytes`-based (`Chunk.bytes`) — # session-only: the alktty chunk plane is `bytes`-based (`Chunk.bytes`),
# the bridge builds sentinel/ctrl chunks with it. Same pin russh/alktty # the bridge builds sentinel/ctrl chunks with it. Same pin russh/alktty
# carry (crate resolution stays single-versioned). # carry (crate resolution stays single-versioned).
bytes = { version = "1", optional = true } bytes = { version = "1", optional = true }
@@ -87,24 +99,24 @@ zeroize = "1.9"
[dev-dependencies] [dev-dependencies]
# The git-seam test doubles name `GitPackGen`'s `gix_hash::ObjectId` # The git-seam test doubles name `GitPackGen`'s `gix_hash::ObjectId`
# parameters and alkgit does not re-export them — test-graph only, # parameters and alkgit does not re-export them (test-graph only),
# version matched to alkgit's `^0.28` req (same crate in the lockfile). # version matched to alkgit's `^0.28` req (same crate in the lockfile).
gix-hash = { version = "0.28", features = ["sha1"] } gix-hash = { version = "0.28", features = ["sha1"] }
# The session shim's scripted stdout/stderr streams ride tokio's # The session shim's scripted stdout/stderr streams ride tokio's
# `ReceiverStream` — test-graph only (the crate proper hand-rolls no # `ReceiverStream`, test-graph only (the crate proper hand-rolls no
# streams). # streams).
tokio-stream = "0.1" tokio-stream = "0.1"
# The leg-2 fabric double's establisher/params serialization # The leg-2 fabric double's establisher/params serialization
# (`w3-tunnel-listen`) — test-graph only (the door's own lib code # (`w3-tunnel-listen`), test-graph only (the door's own lib code
# stays serde-free under the `tunnel` feature; the double's # stays serde-free under the `tunnel` feature; the double's
# dynamic-bind producer serializes params/reply fields). # dynamic-bind producer serializes params/reply fields).
serde_json = "1" serde_json = "1"
# The real-tool pty smoke (`w3-tty-session`): alktty's `local` backend # The real-tool pty smoke (`w3-tty-session`): alktty's `local` backend
# composition — the dev-dep `features = ["local"]` unification applies # composition: the dev-dep `features = ["local"]` unification applies
# to the TEST graph only (feature unification folds `local` into the # to the TEST graph only (feature unification folds `local` into the
# optional alktty dep in test builds; the published lib never enables # optional alktty dep in test builds; the published lib never enables
# it and `src/` never names a local shape). Same 0.8.1 pin. # it and `src/` never names a local shape). Same 0.8.2 pin.
alktty = { version = "0.8.1", default-features = false, features = ["local"] } alktty = { version = "0.8.2", default-features = false, features = ["local"] }
[profile.release] [profile.release]
lto = "thin" lto = "thin"
+19 -19
View File
@@ -2,19 +2,19 @@
SSH adapter for the alk family: a thin wrapper around SSH adapter for the alk family: a thin wrapper around
[russh](https://docs.rs/russh) terminating the vanilla-ssh wire and [russh](https://docs.rs/russh) terminating the vanilla-ssh wire and
bridging to/from alkcall channels — the SSH counterpart of the bridging to/from alkcall channels, the SSH counterpart of the
alkhttp adapter (`family` ADR-003). Vanilla clients terminate vanilla alkhttp adapter (`family` ADR-003). Vanilla clients terminate vanilla
ssh; family protocol halves (alktty, alktunnels, alkgit) receive their ssh; family protocol halves (alktty, alktunnels, alkgit) receive their
own chunk planes; the door itself speaks no family protocol and owns own chunk planes; the door itself speaks no family protocol and owns
no deployment policy. no deployment policy.
The crate is a **door, not a backend**: no binary, no front doors — a The crate is a **door, not a backend**: no binary, no front doors; a
downstream app (alkforge) embeds it, enables the features it wants, downstream app (alkforge) embeds it, enables the features it wants,
and brings identity/ACL configuration and the family fabric handles. and brings identity/ACL configuration and the family fabric handles.
It is two-directional: a server mount serves vanilla ssh into the It is two-directional: a server mount serves vanilla ssh into the
family, and a dial points family ops at a vanilla `sshd`. family, and a dial points family ops at a vanilla `sshd`.
## The feature graph — fail-closed ## The fail-closed feature graph
`default = []` by design: **a default build has no capabilities and `default = []` by design: **a default build has no capabilities and
can serve nothing but clean declines.** Every channel-type mapping is can serve nothing but clean declines.** Every channel-type mapping is
@@ -26,14 +26,14 @@ implies another):
| `session` | pty / shell / exec (pipe) rows → chunk-framed session | alktty | | `session` | pty / shell / exec (pipe) rows → chunk-framed session | alktty |
| `git` | the exec-only git rows (`git-{upload,receive}-pack`) | alkgit | | `git` | the exec-only git rows (`git-{upload,receive}-pack`) | alkgit |
| `tunnel` | `direct-tcpip` / `direct-streamlocal`, the `-R` legs | alktunnels | | `tunnel` | `direct-tcpip` / `direct-streamlocal`, the `-R` legs | alktunnels |
| `sftp` | reserved decline placeholder (OQ-13) — always declines until the alkfs mapping lands | — | | `sftp` | reserved decline placeholder (OQ-13), always declines until the alkfs mapping lands | n/a |
Non-mapped surface (x11, `auth-agent`, the `env` request, git *subsystem* Non-mapped surface (x11, `auth-agent`, the `env` request, git *subsystem*
requests, `sftp` until alkfs) declines fail-closed in every build: requests, `sftp` until alkfs) declines fail-closed in every build:
never a half-capability, never a silent fallback, and never advertised never a half-capability, never a silent fallback, and never advertised;
— an unfeatured channel type is declined, not advertised. A request an unfeatured channel type is declined, not advertised. A request
whose config fields are unset declines too (an unset seam or shell whose config fields are unset declines too (an unset seam or shell
argv configures nothing — the feature alone is not a capability). argv configures nothing; the feature alone is not a capability).
## Usage ## Usage
@@ -42,7 +42,7 @@ The assembly injects the seams: an alkcall
the per-feature family handles. The examples below are the crate's the per-feature family handles. The examples below are the crate's
compile-checked doctests (see the crate docs in `src/lib.rs`). compile-checked doctests (see the crate docs in `src/lib.rs`).
A server mount — per accepted TCP connection, run the door: A server mount (per accepted TCP connection, run the door):
```rust,no_run ```rust,no_run
use std::sync::Arc; use std::sync::Arc;
@@ -77,7 +77,7 @@ let adapter = alkssh::SshAdapter::new(serve).await?;
// adapter.handle(stream).await?; // adapter.handle(stream).await?;
``` ```
A dial — family ops pointed upstream at a vanilla server: A dial, family ops pointed upstream at a vanilla server:
```rust,no_run ```rust,no_run
use std::sync::Arc; use std::sync::Arc;
@@ -102,18 +102,18 @@ let conn = alkssh::dial(alkssh::DialConfig::new(
.await?; .await?;
// op rows (under their features): conn.exec, conn.tunnel_direct, // op rows (under their features): conn.exec, conn.tunnel_direct,
// conn.git_service, conn.reverse_listen — see the crate docs. // conn.git_service, conn.reverse_listen; see the crate docs.
conn.close().await; // clean disconnect — drop is never the clean path conn.close().await; // clean disconnect; drop is never the clean path
``` ```
Minimal builds compile and doctest with no features enabled — Minimal builds compile and doctest with no features enabled:
```console ```console
$ cargo build $ cargo build
$ cargo test $ cargo test
``` ```
— and every capability row additionally rides its feature build (the Every capability row additionally rides its feature build (the
matrix: no features / `git` / `session` / `tunnel` / `sftp` / matrix: no features / `git` / `session` / `tunnel` / `sftp` /
`--all-features`). `--all-features`).
@@ -122,22 +122,22 @@ matrix: no features / `git` / `session` / `tunnel` / `sftp` /
- **Keys are sources, never static material** (alknet-main's `main` - **Keys are sources, never static material** (alknet-main's `main`
key model, ADR-005): the dial/host key seams are key model, ADR-005): the dial/host key seams are
decrypt-at-use handles over an injected decrypt-at-use handles over an injected
[`alkvault`](https://docs.rs/alkvault) service — keys held [`alkvault`](https://docs.rs/alkvault) service; keys are held
encrypted at rest, decrypted at use, zeroized through the seam. encrypted at rest, decrypted at use, zeroized through the seam.
The vault is a secret store on this path, not a key generator. The vault is a secret store on this path, not a key generator.
- **No env vars, no config-file key material, no secrets on the - **No env vars, no config-file key material, no secrets on the
wire.** The standing family posture (family ADR-002 decision 5). wire.** The standing family posture (family ADR-002 decision 5).
- **Publickey-only authentication**, exactly one attempt — no - **Publickey-only authentication**, exactly one attempt; no
password, keyboard-interactive, cert, or fallback methods exist on password, keyboard-interactive, cert, or fallback methods exist on
the surface. the surface.
- **Host-key trust is assembly policy**: the crate ships a - **Host-key trust is assembly policy**: the crate ships a
`HostKeyPolicy` seam with a fail-closed default (`RejectAll` — a `HostKeyPolicy` seam with a fail-closed default (`RejectAll`; a
dial with it connects to nothing) and **no accept-all built-in**; dial with it connects to nothing) and **no accept-all built-in**;
any permissive policy is the assembly's to write. any permissive policy is the assembly's to write.
- **Injected identity**: every wire-exposed op is checked against - **Injected identity**: every wire-exposed op is checked against
alkcall's `AccessControl::check(peer_identity)`; internal ops are alkcall's `AccessControl::check(peer_identity)`; internal ops are
never wire-callable; there is no unauthenticated capability. never wire-callable; there is no unauthenticated capability.
- **Bounded resources everywhere** (`Limits` — wall clock always - **Bounded resources everywhere** (`Limits`: wall clock always
set, channel caps, listener caps, window/packet budgets, auth set, channel caps, listener caps, window/packet budgets, auth
attempt limit): the crate never constructs an unbounded session. attempt limit): the crate never constructs an unbounded session.
Known, recorded limits ride the published docs (the crate-root Known, recorded limits ride the published docs (the crate-root
@@ -149,10 +149,10 @@ matrix: no features / `git` / `session` / `tunnel` / `sftp` /
- Crate docs: `cargo doc --open` (the crate-root docs carry the - Crate docs: `cargo doc --open` (the crate-root docs carry the
feature graph and the known-limits records). feature graph and the known-limits records).
- `docs/architecture/` — the authoritative contract: the spec - `docs/architecture/`: the authoritative contract: the spec
(`overview.md`, `mapping.md`, `client.md`, `auth.md`), ADRs (`overview.md`, `mapping.md`, `client.md`, `auth.md`), ADRs
001–006, and the open-questions tracker. 001–006, and the open-questions tracker.
## License ## License
MIT OR Apache-2.0 — see `LICENSE-MIT` / `LICENSE-APACHE`. MIT OR Apache-2.0; see `LICENSE-MIT` / `LICENSE-APACHE`.
+16 -16
View File
@@ -7,12 +7,12 @@ last_updated: 2026-10-28
## Current State ## Current State
Phase 1 (architecture) — complete pending implementation. Phase 0 is Phase 1 (architecture) is complete pending implementation. Phase 0 is
closed: all three POCs passed (121 checks; closed: all three POCs passed (121 checks;
`docs/research/poc-{1,2,3}-*.md`) and the research docs record their `docs/research/poc-{1,2,3}-*.md`) and the research docs record their
corrections. The ADR set (001–006) is **Accepted** — including the corrections. The ADR set (001–006) is **Accepted**, including the
post-review amendments (ADR-002's ed25519-only revert; ADR-003/006's post-review amendments (ADR-002's ed25519-only revert; ADR-003/006's
`dynamic-forward` retirement) — and the four spec docs `dynamic-forward` retirement), and the four spec docs
(overview/mapping/client/auth) are `reviewed` (2026-10-28): the OQ (overview/mapping/client/auth) are `reviewed` (2026-10-28): the OQ
resolution pass closed all thirteen open questions (OQ-08/11 decided, resolution pass closed all thirteen open questions (OQ-08/11 decided,
OQ-10 converted to a derivation contract + upstream constraint, OQ-10 converted to a derivation contract + upstream constraint,
@@ -24,7 +24,7 @@ pass came back clean. Implementation may begin.
| Document | Status | Scope | | Document | Status | Scope |
|---|---|---| |---|---|---|
| [overview.md](overview.md) | reviewed | Crate purpose, family position, surface categories, bounded-resources policy, non-goals | | [overview.md](overview.md) | reviewed | Crate purpose, family position, surface categories, bounded-resources policy, non-goals |
| [mapping.md](mapping.md) | reviewed | The spine — server-direction rows, dispatch, spawn-and-return, decline matrix | | [mapping.md](mapping.md) | reviewed | The spine: server-direction rows, dispatch, spawn-and-return, decline matrix |
| [client.md](client.md) | reviewed | Client-direction surface: dial, op rows, the acceptor, lifecycle primitives | | [client.md](client.md) | reviewed | Client-direction surface: dial, op rows, the acceptor, lifecycle primitives |
| [auth.md](auth.md) | reviewed | The auth bridge (server) + custody & host-key policy (client) | | [auth.md](auth.md) | reviewed | The auth bridge (server) + custody & host-key policy (client) |
| [open-questions.md](open-questions.md) | reviewed | Centralized OQ tracker (all OQ-01…13 resolved) | | [open-questions.md](open-questions.md) | reviewed | Centralized OQ tracker (all OQ-01…13 resolved) |
@@ -33,24 +33,24 @@ pass came back clean. Implementation may begin.
| ADR | Title | Status | | ADR | Title | Status |
|---|---|---| |---|---|---|
| [ADR-001](decisions/001-alkssh-alpn-pin.md) | The `alk/ssh` ALPN pin — edge-termination contract for `SshAdapter` | Accepted | | [ADR-001](decisions/001-alkssh-alpn-pin.md) | The `alk/ssh` ALPN pin: edge-termination contract for `SshAdapter` | Accepted |
| [ADR-002](decisions/002-russh-pin.md) | The russh pin — 0.65.0, aws-lc-rs, no compression, ed25519-only | Accepted | | [ADR-002](decisions/002-russh-pin.md) | The russh pin (0.65.0, aws-lc-rs, no compression, ed25519-only) | Accepted |
| [ADR-003](decisions/003-feature-graph.md) | The capability feature graph — flat, independent, two-direction | Accepted | | [ADR-003](decisions/003-feature-graph.md) | The capability feature graph: flat, independent, two-direction | Accepted |
| [ADR-004](decisions/004-auth-dispositions.md) | Auth dispositions — publickey-only, fail-closed, fingerprint identity | Accepted | | [ADR-004](decisions/004-auth-dispositions.md) | Auth dispositions: publickey-only, fail-closed, fingerprint identity | Accepted |
| [ADR-005](decisions/005-at-rest-key-custody.md) | At-rest key custody — vault-wrapped OpenSSH-PEM, decrypt-at-use | Accepted | | [ADR-005](decisions/005-at-rest-key-custody.md) | At-rest key custody: vault-wrapped OpenSSH-PEM, decrypt-at-use | Accepted |
| [ADR-006](decisions/006-mapping-bridge-and-dispatch.md) | Mapping decisions — route-1 bridge, exec-only git, session dispatch | Accepted | | [ADR-006](decisions/006-mapping-bridge-and-dispatch.md) | Mapping decisions: route-1 bridge, exec-only git, session dispatch | Accepted |
## Open Questions ## Open Questions
Tracked in [open-questions.md](open-questions.md) — OQ-01 … OQ-13, Tracked in [open-questions.md](open-questions.md), OQ-01 … OQ-13,
seeded from the phase 0 POC findings and research docs. **All thirteen seeded from the phase 0 POC findings and research docs. **All thirteen
are resolved**: the three mapping-detail decisions settled this pass are resolved**: the three mapping-detail decisions settled this pass
(OQ-08 shell convention → assembly-supplied serve-config field, no (OQ-08 shell convention → assembly-supplied serve-config field, no
crate default; OQ-10 → closed as the derivation contract on crate default; OQ-10 → closed as the derivation contract on
`mapping.md`'s exit rows + the upstream PTY-path constraint; OQ-11 → `mapping.md`'s exit rows + the upstream PTY-path constraint; OQ-11 →
universal compile + runtime Unix-gated decline), and the previously universal compile + runtime Unix-gated decline), and the previously
deferred entries closed as fully-decided dispositions (OQ-01/04/09/13 deferred entries closed as fully-decided dispositions (OQ-01/04/09/13,
— the decision surfaces were already fixed by ADR-001/ADR-004 and the whose decision surfaces were already fixed by ADR-001/ADR-004 and the
decline postures; what remained was forecasts about consumers, not decline postures; what remained was forecasts about consumers, not
pending alkssh decisions). Upstream dependencies are listed as watch pending alkssh decisions). Upstream dependencies are listed as watch
items in the tracker, not OQs. items in the tracker, not OQs.
@@ -66,12 +66,12 @@ items in the tracker, not OQs.
## References ## References
- `docs/sdd_process.md` — the SDD process - `docs/sdd_process.md` (the SDD process)
- `docs/research/` — phase 0 (vision, POC plans + findings, russh - `docs/research/`: phase 0 (vision, POC plans + findings, russh
inventory, auth bridge, mapping table, reverse-runner extraction) inventory, auth bridge, mapping table, reverse-runner extraction)
- Family: `/workspace/@alkdev/alkfamily/docs/architecture/decisions/001..003` - Family: `/workspace/@alkdev/alkfamily/docs/architecture/decisions/001..003`
(ADR-003 is the alkssh structural decision) (ADR-003 is the alkssh structural decision)
- Sibling: alkcall ADRs 002/003/004/006/009/010/034/042/051 (ALPN - Sibling: alkcall ADRs 002/003/004/006/009/010/034/042/051 (ALPN
dispatch, auth surface, channels wire/relay); alkhttp ADR-014/073 dispatch, auth surface, channels wire/relay); alkhttp ADR-014/073
(secret-material flow and door-surface posture — the patterns (secret-material flow and door-surface posture, the patterns
ADR-005 and `auth.md` mirror) ADR-005 and `auth.md` mirror)
+34 -34
View File
@@ -3,7 +3,7 @@ status: reviewed
last_updated: 2026-10-28 last_updated: 2026-10-28
--- ---
# Auth — identity is the family's, not SSH's # Auth: identity is the family's, not SSH's
## The model ## The model
@@ -20,43 +20,43 @@ verifies a signature; its auth surface is *dispositional* (ADR-004).
### The bridge ### The bridge
1. `auth_publickey(user, VerifiedPublicKey)` fires (post-verification; 1. `auth_publickey(user, VerifiedPublicKey)` fires (post-verification;
0.65.0 shape — the key is the `.public_key` field, and the client's the 0.65.0 shape has the key as the `.public_key` field, and the
signature `.algorithm` is exposed for optional hardening). client's signature `.algorithm` is exposed for optional hardening).
2. alkssh computes the fingerprint via the `ssh-key` re-export 2. alkssh computes the fingerprint via the `ssh-key` re-export
(`HashAlg::Sha256`) — `SHA256:` + unpadded base64 over the (`HashAlg::Sha256`): `SHA256:` + unpadded base64 over the
standard alphabet (`ssh-keygen -lf` byte-format; not the standard alphabet (`ssh-keygen -lf` byte-format; not the
url-safe alphabet). The string format is a **contract invariant** url-safe alphabet). The string format is a **contract invariant**
(ADR-004): parity with assembly-registered values is proven (ADR-004): parity with assembly-registered values is proven
against independent issuers (`ssh-keygen -lf` — POC-1 §1, pinned against independent issuers (`ssh-keygen -lf`; POC-1 §1, pinned
by the committed vectors in `tests/vectors/`). by the committed vectors in `tests/vectors/`).
3. `resolve_from_fingerprint` → `Some(identity)` → accept; `None` → 3. `resolve_from_fingerprint` → `Some(identity)` → accept; `None` →
reject — fail-closed, and the resolved-identity-`None` path never reject, fail-closed; the resolved-identity-`None` path never
creates a "weakly authorized" session. creates a "weakly authorized" session.
4. On success the identity rides the session (`auth_succeeded`): for 4. On success the identity rides the session (`auth_succeeded`): for
mapped channels it propagates through the alkcall transport mapped channels it propagates through the alkcall transport
(`set_identity`, once) so `AccessControl::check(peer_identity)` (`set_identity`, once) so `AccessControl::check(peer_identity)`
gates every mapped open — the **visible-surface invariant** holds gates every mapped open; the **visible-surface invariant** holds
in the SSH direction with no SSH-shaped exception. POC-3 §C.3 in the SSH direction with no SSH-shaped exception. POC-3 §C.3
observed the propagation live (the establisher witness). observed the propagation live (the establisher witness).
### Dispositions (ADR-004 — summarized) ### Dispositions (ADR-004, summarized)
`publickey` is the only implemented method, and the only accepted key `publickey` is the only implemented method, and the only accepted key
algorithm is **ed25519** — `auth_publickey` declines non-ed25519 algorithm is **ed25519**, so `auth_publickey` declines non-ed25519
public keys explicitly (the family's ed25519-only posture, ADR-002 public keys explicitly (the family's ed25519-only posture, ADR-002
decision 4; one key algorithm carries the family's identity — the decision 4; one key algorithm carries the family's identity: the
same ed25519 keys behind alktls's self-signed certs, iroh, and the same ed25519 keys behind alktls's self-signed certs, iroh, and the
alknet rewrite). `auth_publickey_offered` alknet rewrite). `auth_publickey_offered`
accepts-and-delegates (keeping `max_auth_attempts` honest — the hook accepts-and-delegates (keeping `max_auth_attempts` honest; the hook
does not consume an attempt, POC-1 verified); `auth_none` / does not consume an attempt, POC-1 verified); `auth_none` /
`auth_password` / `auth_keyboard_interactive` / `auth_openssh_certificate` `auth_password` / `auth_keyboard_interactive` / `auth_openssh_certificate`
reject explicitly (at this pin russh's defaults already reject; the reject explicitly (at this pin russh's defaults already reject; the
explicit implementations are the family contract — a pin bump could explicit implementations are the family contract, since a pin bump could
change defaults silently). `Config::methods` advertises `{publickey}` change defaults silently). `Config::methods` advertises `{publickey}`,
— exactly what is implemented. The username is **not identity and exactly what is implemented. The username is **not identity and
carries no semantics** (OQ-03 resolved); log exposure is deployment carries no semantics** (OQ-03 resolved); log exposure is deployment
policy. `max_auth_attempts` defaults `10` (the OpenSSH `none`-probe policy. `max_auth_attempts` defaults `10` (the OpenSSH `none`-probe
consumes one — POC-1 §4). consumes one, POC-1 §4).
## Client direction ## Client direction
@@ -66,37 +66,37 @@ Held keys are bearer-token-class secrets: **vault-encrypted at rest
(alkvault `EncryptedData`, OpenSSH-PEM inner), decrypted at use, (alkvault `EncryptedData`, OpenSSH-PEM inner), decrypted at use,
zeroized ownership at the adapter seam**; never env vars, never zeroized ownership at the adapter seam**; never env vars, never
plaintext at rest, never persisted after use. The vault is a secret plaintext at rest, never persisted after use. The vault is a secret
store on this path — not a key generator, not a custodian, and never store on this path, not a key generator, not a custodian, and never
involved in resolving *peers'* fingerprints. Failure modes are all involved in resolving *peers'* fingerprints. Failure modes are all
fail-closed with distinct errors: locked / absent / decryption fail-closed with distinct errors: locked / absent / decryption
failure / unknown `key_version` — and rotation preserves key identity. failure / unknown `key_version`, and rotation preserves key identity.
The same model covers **host keys** (decrypt-at-construction into the The same model covers **host keys** (decrypt-at-construction into the
server's `Config::keys`; locked vault → **no server is constructed** — server's `Config::keys`; locked vault → **no server is constructed**;
POC-2 §5). POC-2 §5).
### Host-key policy (OQ-05 resolution; the client-side trust contract) ### Host-key policy (OQ-05 resolution; the client-side trust contract)
Two cases, one seam — the crate is **policy-blind**: Two cases, one seam; the crate is **policy-blind**:
1. **Known beforehand** (anchors exist; family-built runners mint 1. **Known beforehand** (anchors exist; family-built runners mint
their host key at assembly time): assembly-supplied pins — their host key at assembly time): assembly-supplied pins, with a
**hard-decline on mismatch** at `check_server_key`. **hard decline on mismatch** at `check_server_key`.
2. **Unknown beforehand** (vanilla `sshd` on rental fleets; no anchor 2. **Unknown beforehand** (vanilla `sshd` on rental fleets; no anchor
until contact): the crate surfaces until contact): the crate surfaces
(host, port, presented fingerprint) to an **assembly-provided (host, port, presented fingerprint) to an **assembly-provided
`HostKeyPolicy`** — prompt-and-record, provider-metadata match, `HostKeyPolicy`**, which chooses prompt-and-record,
learn-and-record, or accept-and-continue (the ephemeral-fleet provider-metadata match, learn-and-record, or accept-and-continue
shape). The OpenSSH "add to known_hosts?" moment becomes (the ephemeral-fleet shape). The OpenSSH "add to known_hosts?"
assembly policy. moment becomes assembly policy.
Crate rules: default policy is fail-closed (reject unknown); the Crate rules: default policy is fail-closed (reject unknown); the
crate never decides trust and never hard-codes a trust model — crate never decides trust and never hard-codes a trust model, so
accept-all is not a built-in crate option, but a permissive case-2 accept-all is not a built-in crate option, but a permissive case-2
policy is the assembly's to choose where its fleet model calls for policy is the assembly's to choose where its fleet model calls for
it (alkminer's accept-all for rental fleets is that shape, made it (alkminer's accept-all for rental fleets is that shape, made
deliberately: fresh container per rent → fresh host key, so no pin deliberately: fresh container per rent → fresh host key, so no pin
can exist beforehand; the security anchor there is hub-held can exist beforehand; the security anchor there is hub-held
per-instance dial keys — ADR-005's custody model — plus what rides per-instance dial keys (ADR-005's custody model) plus what rides
the tunnels, not host pins). the tunnels, not host pins).
## What is out of scope ## What is out of scope
@@ -104,21 +104,21 @@ the tunnels, not host pins).
- Password / keyboard-interactive anywhere (server: declined; client: - Password / keyboard-interactive anywhere (server: declined; client:
non-goal until a use case). non-goal until a use case).
- OpenSSH certificate auth (`auth_openssh_certificate` declined - OpenSSH certificate auth (`auth_openssh_certificate` declined
unconditionally — OQ-04 resolved: no CA-trust story exists in the unconditionally; OQ-04 resolved: no CA-trust story exists in the
family identity model; future support is new-ADR territory with the family identity model; future support is new-ADR territory with the
upstream identity-model work as its prerequisite). upstream identity-model work as its prerequisite).
- ssh-agent integration/forwarding (external signer processes are out - ssh-agent integration/forwarding (external signer processes are out
of family scope; the vault is the custody story). of family scope; the vault is the custody story).
- TOFU *as crate behavior* — policy lives at the assembly, the - TOFU *as crate behavior*, since policy lives at the assembly: the
crate is policy-blind (above). crate is policy-blind (above).
## Open Questions ## Open Questions
None open or deferred — see [open-questions.md](open-questions.md) None open or deferred; see [open-questions.md](open-questions.md)
(all of OQ-01…13 resolved). Doc-local contract anchors: OQ-03 (the (all of OQ-01…13 resolved). Doc-local contract anchors: OQ-03 (the
username disposition — §Dispositions), OQ-04 (the certificate decline username disposition, §Dispositions), OQ-04 (the certificate decline,
— §out-of-scope), OQ-05 (the host-key policy seam — §Host-key policy), §out-of-scope), OQ-05 (the host-key policy seam, §Host-key policy),
OQ-06 / OQ-07 (custody boundaries — ADR-005). OQ-06 / OQ-07 (custody boundaries, ADR-005).
## References ## References
@@ -128,6 +128,6 @@ OQ-06 / OQ-07 (custody boundaries — ADR-005).
refinement), `poc-1-auth-bridge.md` (§1–4), `poc-2-vault-keys.md` refinement), `poc-1-auth-bridge.md` (§1–4), `poc-2-vault-keys.md`
(§2–5), `reverse-runner.md` (lessons 5–6) (§2–5), `reverse-runner.md` (lessons 5–6)
- alkcall ADRs 002/003/006 (identity surface) and 010 (secret-material - alkcall ADRs 002/003/006 (identity surface) and 010 (secret-material
flow — alkhttp ADR-014's alkcall analog), alkhttp ADR-073 (one flow, alkhttp ADR-014's alkcall analog), alkhttp ADR-073 (one
backend behind every door surface), family ADR-002 decision 5 / backend behind every door surface), family ADR-002 decision 5 /
family ADR-003 decision 2 family ADR-003 decision 2
+57 -55
View File
@@ -3,14 +3,14 @@ status: reviewed
last_updated: 2026-10-29 last_updated: 2026-10-29
--- ---
# Client — client direction: driving vanilla SSH servers as family ops # Client (client direction): driving vanilla SSH servers as family ops
## The door framing ## The door framing
The client direction is a door *to vanilla servers*: family in, The client direction is a door *to vanilla servers*: family in,
vanilla out. The motivating consumer is the **reverse-runner** shape vanilla out. The motivating consumer is the **reverse-runner** shape
(`docs/research/reverse-runner.md`, alkminer extraction): a family hub (`docs/research/reverse-runner.md`, alkminer extraction): a family hub
dials vanilla `sshd` on rented/near-ephemeral hosts — remote boxes dials vanilla `sshd` on rented/near-ephemeral hosts; the remote boxes
expose plain sshd and hold nothing of ours; every op the hub drives expose plain sshd and hold nothing of ours; every op the hub drives
maps family-side onto the same protocols the server direction serves. maps family-side onto the same protocols the server direction serves.
The asymmetry is honest (vision principle 2): the server direction The asymmetry is honest (vision principle 2): the server direction
@@ -24,76 +24,77 @@ up into the family surface.
## The dial surface ## The dial surface
Every dial needs: address, **key source** (ADR-005 — vault-backed Every dial needs: address, **key source** (ADR-005, a vault-backed
blob, decrypt-at-dial, fail-closed on locked/absent — never env vars, blob: decrypt-at-dial, fail-closed on locked/absent, never env vars,
never plaintext at rest), **host-key policy** (OQ-05 resolution — never plaintext at rest), **host-key policy** (the OQ-05 resolution,
assembly-provided; crate default fail-closed reject-unknown, hard assembly-provided; crate default fail-closed reject-unknown, hard
decline on mismatch; the OpenSSH "add to known_hosts?" moment is an decline on mismatch; the OpenSSH "add to known_hosts?" moment is an
assembly policy, e.g. learn-and-record / prompt / provider-metadata assembly policy, e.g. learn-and-record / prompt / provider-metadata
match — the crate is policy-blind), and the bounded-resource config match, and the crate is policy-blind), and the bounded-resource config
(`keepalive_interval`/`keepalive_max` — 15 s / 3 default posture per (`keepalive_interval`/`keepalive_max`, a 15 s / 3 default posture per
the reverse-runner evidence; `inactivity_timeout`; window/packet the reverse-runner evidence; `inactivity_timeout`; window/packet
knobs per `overview.md`). knobs per `overview.md`).
The identity the dial authenticates *as* is a registered fingerprint The identity the dial authenticates *as* is a registered fingerprint
at the remote end — for family-run peers (alkforge runner) that's the at the remote end: for family-run peers (alkforge runner) that's the
alkcall `IdentityProvider`; for vanilla fleets it's whatever the alkcall `IdentityProvider`; for vanilla fleets it's whatever the
vanilla server's `authorized_keys` holds — either way the *key* is vanilla server's `authorized_keys` holds. Either way the *key* is
ours from the vault and the host's key is checked by the policy. ours from the vault and the host's key is checked by the policy.
## Op rows (family op → vanilla translation) ## Op rows (family op → vanilla translation)
| Family-facing op | Family protocol/types | Vanilla-wire translation | Feature | | Family-facing op | Family protocol/types | Vanilla-wire translation | Feature |
|---|---|---|---| |---|---|---|---|
| run a command / own a shell on the runner | alktty session types (pipe vs PTY; exit semantics) | `channel_open_session` + `exec`/pty-req/shell-req; data pump; drain `wait()` per the POC-3 §D.3 mechanics (`Channel::exec()` — no `request_exec`; Success/Failure arrive on the channel queue, not as `Err` from the request call) | `session` | | run a command / own a shell on the runner | alktty session types (pipe vs PTY; exit semantics) | `channel_open_session` + `exec`/pty-req/shell-req; data pump; drain `wait()` per the POC-3 §D.3 mechanics (`Channel::exec()`, not `request_exec`; Success/Failure arrive on the channel queue, not as `Err` from the request call) | `session` |
| open a tunnel target | alktunnels `TunnelSession`/target types | `channel_open_direct_tcpip` per connection — the typed open resolves to `Err(ChannelOpenFailure)` on server reject (POC-1 §6); no `wait()` before `into_stream` | `tunnel` | | open a tunnel target | alktunnels `TunnelSession`/target types | `channel_open_direct_tcpip` per connection; the typed open resolves to `Err(ChannelOpenFailure)` on server reject (POC-1 §6); no `wait()` before `into_stream` | `tunnel` |
| the `-D` behavior (local SOCKS door) | alksocks' local door **composed at the assembly**, fed a `DialFn` backed by row 2's open | the wire sees only `direct-tcpip`; alkssh *does not depend on alksocks* on this path (ADR-003 §1a) | `tunnel` | | the `-D` behavior (local SOCKS door) | alksocks' local door **composed at the assembly**, fed a `DialFn` backed by row 2's open | the wire sees only `direct-tcpip`; alkssh *does not depend on alksocks* on this path (ADR-003 §1a) | `tunnel` |
| listen + ingest (`-R` leg) | alktunnels consumer/`ForwardedHandler` shape | `tcpip_forward(address, port) -> bound` global request (no mpsc sink param) + **accept the server-initiated `forwarded-tcpip` opens** via `server_channel_open_forwarded_tcpip`, routing each to the family handler | `tunnel` | | listen + ingest (`-R` leg) | alktunnels consumer/`ForwardedHandler` shape | `tcpip_forward(address, port) -> bound` global request (no mpsc sink param) + **accept the server-initiated `forwarded-tcpip` opens** via `server_channel_open_forwarded_tcpip`, routing each to the family handler | `tunnel` |
| upstream git consumption | alkgit client/service types | the same exec grammar the server side parses (`git-(upload,receive)-pack '/repo'` exec), pointed at an upstream `sshd` | `git` | | upstream git consumption | alkgit client/service types | the same exec grammar the server side parses (`git-(upload,receive)-pack '/repo'` exec), pointed at an upstream `sshd` | `git` |
| artifact/deploy transfer | alkfs client half (future) | `subsystem "sftp"` + the alkfs protocol | `sftp` (decline placeholder — OQ-13 resolved) | | artifact/deploy transfer | alkfs client half (future) | `subsystem "sftp"` + the alkfs protocol | `sftp` (decline placeholder, OQ-13 resolved) |
## The acceptor: fail-closed client-side ## The acceptor: fail-closed client-side
russh's client handler receives server-initiated channel opens; its russh's client handler receives server-initiated channel opens; its
default posture for a forwarded open is **accept-and-drop** — a default posture for a forwarded open is **accept-and-drop**, a
half-capability, which the family forbids (ADR-003 symmetry + half-capability, which the family forbids (ADR-003 symmetry +
`reverse-runner.md` lesson 1). The client contract: `reverse-runner.md` lesson 1). The client contract:
- Server-initiated opens are served only when the assembly registered - Server-initiated opens are served only when the assembly registered
an acceptor **for that type and under its feature** (`forwarded-tcpip` an acceptor **for that type and under its feature** (the real case
under `tunnel` is the real case — the `-R` data path). is `forwarded-tcpip` under `tunnel`, the `-R` data path).
- Unregistered/unfeatured accepted opens are **declined cleanly**, - Unregistered/unfeatured accepted opens are **declined cleanly**,
never silently dropped after accept. never silently dropped after accept.
- Acceptor handlers may be installed across a live connection's - Acceptor handlers may be installed across a live connection's
lifetime (the alkminer `install_forward_sink` late-install dynamic — lifetime (the alkminer `install_forward_sink` late-install dynamic:
consumers turn capabilities on/off per connection; the shape is an consumers turn capabilities on/off per connection; the shape is an
assembly-mutable acceptor registry). assembly-mutable acceptor registry).
> **The landed acceptor shape (`w4-reverse-listen` — recorded at > **The landed acceptor shape (`w4-reverse-listen`, recorded at
> landing):** the registry is **per-type fixed slots** — exactly the > landing):** the registry is **per-type fixed slots**, exactly the
> two `-R` data-leg types the client direction can serve > two `-R` data-leg types the client direction can serve
> (`forwarded-tcpip`, `forwarded-streamlocal`, both under `tunnel`; > (`forwarded-tcpip`, `forwarded-streamlocal`, both under `tunnel`;
> no map, no generic key), one acceptor trait per type; install is > no map, no generic key), one acceptor trait per type; install is
> **refuse-never-replace** (the `ListenRouter::register` recorded > **refuse-never-replace** (the `ListenRouter::register` recorded
> posture, mirrored client-side); the slots are read **at arrival > posture, mirrored client-side); the slots are read **at arrival
> time** by the dial handler's hooks — a pre-install/unfeatured open > time** by the dial handler's hooks, so a pre-install/unfeatured open
> is declined at arrival, never buffered. The payload an accepted > is declined at arrival, never buffered. The payload an accepted
> open hands the assembly: the channel duplex (family-typed — the > open hands the assembly: the channel duplex (family-typed, the
> `-L` rows' `TunnelChannel` byte duplex, one type both legs ride) + > `-L` rows' `TunnelChannel` byte duplex, one type both legs ride) +
> the open's echo/originator fields; **the pump is the acceptor's** > the open's echo/originator fields; **the pump is the acceptor's**
> from the handoff on (the family handler shape) — the crate keeps no > from the handoff on (the family handler shape), and the crate keeps no
> accepted-open ledger. Bounded bookkeeping, the cap relation: the > accepted-open ledger. Bounded bookkeeping, the cap relation: the
> registry bounded by construction (two slots); the > registry bounded by construction (two slots); the
> **reverse-listen count** and the **accepted-open handoff count** > **reverse-listen count** and the **accepted-open handoff count**
> are two dedicated counters, each against the same > are two dedicated counters, each against the same
> `Limits::max_concurrent_channels` ceiling (the server-side > `Limits::max_concurrent_channels` ceiling (the server-side
> listener/ListenerCap posture mirrored — independent counts, one > listener/ListenerCap posture mirrored: independent counts, one
> ceiling; the listens live at the peer, the counter exists so a dial > ceiling; the listens live at the peer, and the counter exists so a dial
> cannot mint an unbounded listener set). The **forward-request rows > cannot mint an unbounded listener set). The **forward-request rows
> gate on the registered acceptor** (the issuing-side disposition: > gate on the registered acceptor** (the issuing-side disposition:
> a listen whose data leg cannot serve is never asked for — > a listen whose data leg cannot serve is never asked for;
> install-first, `OpError::NoAcceptor` pre-wire), plus > install-first, `OpError::NoAcceptor` pre-wire), plus
> `OpError::ListenRefused` (the peer's `RequestDenied` — denied > `OpError::ListenRefused` (the peer's `RequestDenied`:
> denied
> listens and unknown-listener cancels, sshd raw-string parity) and > listens and unknown-listener cancels, sshd raw-string parity) and
> `OpError::ListenCapExceeded` (the ceiling, pre-wire). The > `OpError::ListenCapExceeded` (the ceiling, pre-wire). The
> **runtime-Unix gate** rides the unix *listen* rows only (the bind > **runtime-Unix gate** rides the unix *listen* rows only (the bind
@@ -108,28 +109,28 @@ half-capability, which the family forbids (ADR-003 symmetry +
`tcpip_forward -> u32`); every listener/handle is explicitly `tcpip_forward -> u32`); every listener/handle is explicitly
cancellable; disconnects are explicit (`Disconnect::ByApplication`). cancellable; disconnects are explicit (`Disconnect::ByApplication`).
- **No reconnect or restore policy is owned by the crate.** - **No reconnect or restore policy is owned by the crate.**
Reconnect-and-restore (rebinding persisted tunnel ports — the Reconnect-and-restore (rebinding persisted tunnel ports, the
alkminer flow) is assembly policy; the crate provides the alkminer flow) is assembly policy; the crate provides the
primitives (bound ports, handles, cancellation, clean close) to primitives (bound ports, handles, cancellation, clean close) to
make that possible. make that possible.
> **The landed lifecycle surface (`w4-lifecycle` — recorded at > **The landed lifecycle surface (`w4-lifecycle`, recorded at
> landing):** the `-R` listens return **`ReverseListener` handles** — > landing):** the `-R` listens return **`ReverseListener` handles**,
> the `ReverseEndpoint` carried (the *actually bound* values: > with the `ReverseEndpoint` carried (the *actually bound* values:
> the wire address + the bound port / the socket path — the > the wire address + the bound port / the socket path, the
> assembly's own record for a rebind policy it may compose), the > assembly's own record for a rebind policy it may compose) and the
> explicit `cancel()` the wire-honest teardown (the confirmed cancel > explicit `cancel()` as the wire-honest teardown (the confirmed cancel
> also releases the connection's listener count; a repeat rides the > also releases the connection's listener count; a repeat rides the
> wire and hits the unknown-listener refusal). The **stateless** > wire and hits the unknown-listener refusal). The **stateless**
> cancel forms (`reverse_listen_cancel` + the unix twin) ride beside > cancel forms (`reverse_listen_cancel` + the unix twin) ride beside
> the handles 1:1 (sshd's global-request cancels are stateless — a > the handles 1:1 (sshd's global-request cancels are stateless, so a
> cancel by the raw wire strings works from anywhere, including > cancel by the raw wire strings works from anywhere, including
> after the handle itself is gone: the handle shares the > after the handle itself is gone: the handle shares the
> connection's internals through the arc, so its `cancel()` is the > connection's internals through the arc, so its `cancel()` is the
> same wire op). **No `Drop` hides a wire op** on any of these > same wire op). **No `Drop` hides a wire op** on any of these
> handles: a cancellation/bundle of the wire ops is async and cannot > handles: a cancellation/bundle of the wire ops is async and cannot
> run in a sync `Drop`, and at this russh pin dropping the last > run in a sync `Drop`, and at this russh pin dropping the last
> wire-stack handle ends the client runner — the transport *breaks* > wire-stack handle ends the client runner, so the transport *breaks*
> (the peer observes an error-class session end, never the clean > (the peer observes an error-class session end, never the clean
> disconnect message). The primitives-not-policy posture is the > disconnect message). The primitives-not-policy posture is the
> module-docs contract: `client.rs` §Lifecycle posture (the > module-docs contract: `client.rs` §Lifecycle posture (the
@@ -140,36 +141,36 @@ half-capability, which the family forbids (ADR-003 symmetry +
## What the crate does *not* include (client direction) ## What the crate does *not* include (client direction)
- **Job supervision** — background-job lifecycle (the alkminer - **Job supervision**: background-job lifecycle (the alkminer
start/stop/logs job model, pid files, host-side ledgers) is start/stop/logs job model, pid files, host-side ledgers) is
assembly work composed over the exec rows; the crate gives the assembly work composed over the exec rows; the crate gives the
channels and the exit semantics (including the pipe-mode channels and the exit semantics (including the pipe-mode
pid-only signal caveat — `mapping.md`'s derivation contract). pid-only signal caveat from `mapping.md`'s derivation contract).
- **Credential storage policy** — assembly (the vault handle is - **Credential storage policy**: assembly (the vault handle is
injected; ADR-005 / OQ-06). injected; ADR-005 / OQ-06).
- **Trust decisions** — the host-key policy is assembly-provided - **Trust decisions**: the host-key policy is assembly-provided
(OQ-05; the crate default rejects; a permissive case-2 policy for (OQ-05; the crate default rejects; a permissive case-2 policy for
ephemeral fleets is the assembly's to choose — the crate carries no ephemeral fleets is the assembly's to choose, since the crate
trust model of its own). carries no trust model of its own).
- **sftp** — the `sftp` feature is the tested decline placeholder on - **sftp**: the `sftp` feature is the tested decline placeholder on
both directions (OQ-13 resolved); the alkfs-protocol mapping both directions (OQ-13 resolved); the alkfs-protocol mapping
arrives with alkfs, as its own ADR. arrives with alkfs, as its own ADR.
## Correctness facts to carry (POC-recorded) ## Correctness facts to carry (POC-recorded)
> **The landed op-row shape (`w4-op-rows` — recorded at landing):** > **The landed op-row shape (`w4-op-rows`, recorded at landing):**
> the exit-*consuming* rows (session exec/pty, git exec) ride the > the exit-*consuming* rows (session exec/pty, git exec) ride the
> **split-halves wait-pump** (`Channel::split()` + a spawned task > **split-halves wait-pump** (`Channel::split()` + a spawned task
> draining `wait()`), never `into_stream()` — the conversion's reader > draining `wait()`), never `into_stream()`: the conversion's reader
> side swallows exactly the non-`Data` messages the exit reports ride > side swallows exactly the non-`Data` messages the exit reports ride
> on, so a wait-pump is the only shape that can consume them (the > on, so a wait-pump is the only shape that can consume them (the
> same reason `w4-git-exit-close` pinned the split-halves shape > same reason `w4-git-exit-close` pinned the split-halves shape
> server-side). Only the exit-less rows (the tunnel directs) use > server-side). Only the exit-less rows (the tunnel directs) use
> `into_stream()`, straight after the typed open — no queue > `into_stream()`, straight after the typed open; no queue
> consumption ever precedes a conversion, which is POC-1 §6's caveat > consumption ever precedes a conversion, which is POC-1 §6's caveat
> respected by construction. The session rows' family plane is the > respected by construction. The session rows' family plane is the
> alktty chunk framing both directions (stdin/`CTRL_IN` out, stdout/ > alktty chunk framing both directions (stdin/`CTRL_IN` out, stdout/
> stderr in, the family exit chunk in-band, last — `exit_code.rs`'s > stderr in, the family exit chunk in-band, last, per `exit_code.rs`'s
> two-arm contract: the server arm derives wire reports from family > two-arm contract: the server arm derives wire reports from family
> codes, the client arm mirrors wire reports into family codes, > codes, the client arm mirrors wire reports into family codes,
> `−signum` through the one table, the `−1` sentinel never widened). > `−signum` through the one table, the `−1` sentinel never widened).
@@ -180,28 +181,29 @@ half-capability, which the family forbids (ADR-003 symmetry +
- `Channel::exec()` (no `request_exec` at this pin); pty/subsystem - `Channel::exec()` (no `request_exec` at this pin); pty/subsystem
request failures surface as `ChannelMsg::Failure` via `wait()`, not request failures surface as `ChannelMsg::Failure` via `wait()`, not
`Err` from the call — the caller must drain until the expected `Err` from the call; the caller must drain until the expected
message, `continue` past the rest. message and `continue` past the rest.
- `window_change`/`signal` are fire-and-forget and must be sent - `window_change`/`signal` are fire-and-forget and must be sent
**before** `into_stream()` (the Channel is consumed by conversion); **before** `into_stream()` (the Channel is consumed by conversion);
server-side they carry no SSH reply. server-side they carry no SSH reply.
- Do not consume the channel queue before `into_stream()` — a - Do not consume the channel queue before `into_stream()`: a
`wait()` on a fresh channel breaks it (POC-1 §6 caveat), both sides. `wait()` on a fresh channel breaks it (POC-1 §6 caveat), both sides.
- Keepalives on cloud fleets: 15 s / 3 (OpenSSH - Keepalives on cloud fleets: 15 s / 3 (OpenSSH
`ServerAliveInterval=15`/`CountMax=3` parity) — with russh's `ServerAliveInterval=15`/`CountMax=3` parity), since with russh's
default (none), dead NAT'd connections sit unnoticed until an exec default (none) dead NAT'd connections sit unnoticed until an exec
health check fails (`reverse-runner.md` lesson 3). health check fails (`reverse-runner.md` lesson 3).
- A rejected channel open is an `Err` from the typed open call (not a - A rejected channel open is an `Err` from the typed open call (not a
`wait()`-match); the connection stays usable after declines. `wait()`-match); the connection stays usable after declines.
## Open Questions ## Open Questions
None open or deferred — see [open-questions.md](open-questions.md) None open or deferred; see [open-questions.md](open-questions.md)
(all of OQ-01…13 resolved). Doc-local contract anchors: OQ-05 (the (all of OQ-01…13 resolved). Doc-local contract anchors: OQ-05 (the
host-key policy — §The dial surface), OQ-12 (lifecycle primitives only host-key policy, §The dial surface), OQ-12 (lifecycle primitives only,
— §Lifecycle primitives), OQ-13 (the sftp decline placeholder — §op §Lifecycle primitives), OQ-13 (the sftp decline placeholder, §op
rows and §not-include), OQ-07 (the adapter-side `Zeroizing` wrap — rows and §not-include), OQ-07 (closed upstream: the vault returns a
ADR-005 decision 3). `Zeroizing<String>` from alkvault 0.2.0; ADR-005 decision 3 amended,
the adapter-side wrap dropped).
## References ## References
@@ -3,12 +3,12 @@ status: reviewed
last_updated: 2026-10-28 last_updated: 2026-10-28
--- ---
# ADR-001: The `alk/ssh` ALPN pin — edge-termination contract for `SshAdapter` # ADR-001: The `alk/ssh` ALPN pin (edge-termination contract for `SshAdapter`)
## Status ## Status
**Accepted** — 2026-10-26. One-way door (ledger rule 1: ALPN registry **Accepted** (2026-10-26). One-way door (ledger rule 1: ALPN registry
entries are stable — "once published, does not change"). The first entries are stable; "once published, does not change"). The first
consumer is alkforge (family ADR-003 decision 4: the pin lands before consumer is alkforge (family ADR-003 decision 4: the pin lands before
alkforge becomes the consumer). alkforge becomes the consumer).
@@ -21,11 +21,11 @@ pin means:
- **ADR-004** (ALPN convention + connection model) reserves - **ADR-004** (ALPN convention + connection model) reserves
`alk/ssh` → `SshAdapter` as a custom protocol. `alk/ssh` → `SshAdapter` as a custom protocol.
- **ADR-034** (channels wire format) grants `alk/ssh` stream_types - **ADR-034** (channels wire format) grants `alk/ssh` stream_types
`[0, 1]` — "SSH multiplexes internally, including its own control". `[0, 1]` ("SSH multiplexes internally, including its own control").
The channels layer is byte-blind to SSH; it must never parse SSH The channels layer is byte-blind to SSH; it must never parse SSH
channel data. channel data.
- **ADR-042 / ADR-051** (hub relay): "The hub never runs a handler for - **ADR-042 / ADR-051** (hub relay): "The hub never runs a handler for
`alk/tty`, `alk/ssh`, or `alk/tunnel`" — SSH channels are relayed `alk/tty`, `alk/ssh`, or `alk/tunnel`"; SSH channels are relayed
payload-blind between spokes (translate + byte-forward with id payload-blind between spokes (translate + byte-forward with id
rewrite, ACL fired at `channel/open` by the hub's `CallAdapter`). rewrite, ACL fired at `channel/open` by the hub's `CallAdapter`).
- **ADR-009** (BiStream handler leaf) sketches the browser route: a - **ADR-009** (BiStream handler leaf) sketches the browser route: a
@@ -36,12 +36,12 @@ pin means:
Phase 0 left the topology open (`mapping-table.md` §topology; vision Phase 0 left the topology open (`mapping-table.md` §topology; vision
scope question 1): whether `alk/ssh` carries raw vanilla-SSH bytes scope question 1): whether `alk/ssh` carries raw vanilla-SSH bytes
between alkssh instances (a relay leg), serves the browser route, or between alkssh instances (a relay leg), serves the browser route, or
both — and how that coexists with ADR-042's relay ruling. POC-3 both, and how that coexists with ADR-042's relay ruling. POC-3
settled what could be settled by evidence: the served topology is settled what could be settled by evidence: the served topology is
**edge termination** (russh terminates vanilla SSH at the alkssh **edge termination** (russh terminates vanilla SSH at the alkssh
endpoint; family seams are consumed in-process or via `alk/channels` endpoint; family seams are consumed in-process or via `alk/channels`
bridge legs), worked end to end with vanilla clients through published bridge legs), worked end to end with vanilla clients through published
sibling crates — and no leg of it requires the hub to parse SSH. sibling crates, and no leg of it requires the hub to parse SSH.
The decision that remains is the *pin*: what contract `alk/ssh` The decision that remains is the *pin*: what contract `alk/ssh`
commits to on the ledger, so the ALPN string cannot be reinterpreted commits to on the ledger, so the ALPN string cannot be reinterpreted
@@ -52,10 +52,10 @@ later.
1. **The pin.** `alk/ssh` is pinned for exactly one contract: a 1. **The pin.** `alk/ssh` is pinned for exactly one contract: a
connection opened under `alk/ssh` carries a **raw vanilla-SSH byte connection opened under `alk/ssh` carries a **raw vanilla-SSH byte
stream**, unchanged and uninterpreted, and the receiving endpoint stream**, unchanged and uninterpreted, and the receiving endpoint
is `SshAdapter::handle(Connection)` — alkssh's russh server stack is `SshAdapter::handle(Connection)`, alkssh's russh server stack
mounted at an alkcall `channel/open` boundary. Each bidi stream is mounted at an alkcall `channel/open` boundary. Each bidi stream is
one independent SSH transport with its own KEX, auth, and channel one independent SSH transport with its own KEX, auth, and channel
multiplexing (hence ADR-034's `[0, 1]` — SSH owns its own multiplexing (hence ADR-034's `[0, 1]`: SSH owns its own
multiplexing, and the channels layer stays byte-blind, per multiplexing, and the channels layer stays byte-blind, per
ADR-042's relay ruling). ADR-042's relay ruling).
2. **Payload-blind relay is part of the contract.** Any intermediate 2. **Payload-blind relay is part of the contract.** Any intermediate
@@ -64,8 +64,8 @@ later.
`channel/open` and never an SSH handler. This is ADR-042's ruling, `channel/open` and never an SSH handler. This is ADR-042's ruling,
restated as the ALPN's own contract so it cannot drift. restated as the ALPN's own contract so it cannot drift.
3. **Phase 1 serves edge termination only.** The first deployment 3. **Phase 1 serves edge termination only.** The first deployment
shape — russh terminating vanilla SSH at the alkssh edge, family shape (russh terminating vanilla SSH at the alkssh edge, family
seams consumed in-process — does not speak `alk/ssh` itself: it is seams consumed in-process) does not speak `alk/ssh` itself: it is
a door, not a relay party. No pin behavior in this ADR requires the a door, not a relay party. No pin behavior in this ADR requires the
relay leg to exist. relay leg to exist.
4. **The relay leg and the browser-WASM route remain compatible by 4. **The relay leg and the browser-WASM route remain compatible by
@@ -77,7 +77,7 @@ later.
surface; nothing in the crate is gated on a consumer). surface; nothing in the crate is gated on a consumer).
5. **What the pin is not.** It is not a carrier for alkcall's framed 5. **What the pin is not.** It is not a carrier for alkcall's framed
channels traffic (that has its own protocol); it is not a second channels traffic (that has its own protocol); it is not a second
SSH-flavored control channel; and honesty cuts both ways — an SSH-flavored control channel; and honesty cuts both ways: an
endpoint that declines to serve `alk/ssh` must refuse the open, endpoint that declines to serve `alk/ssh` must refuse the open,
never accept-and-stall (the same honest-advertisement rule that never accept-and-stall (the same honest-advertisement rule that
runs through every alkssh decision). runs through every alkssh decision).
@@ -88,18 +88,18 @@ later.
(frozen here) from the *served edge topology* (which evolves with (frozen here) from the *served edge topology* (which evolves with
the family seams); the browser and relay routes are pre-enabled at the family seams); the browser and relay routes are pre-enabled at
zero pin cost; the ledger gets a single unambiguous entry. zero pin cost; the ledger gets a single unambiguous entry.
- **Negative**: one-way door — the raw-vanilla-SSH-stream contract - **Negative**: the pin is a one-way door; the raw-vanilla-SSH-stream
binds every future consumer (alkforge first). If a consumer ever contract binds every future consumer (alkforge first). If a consumer ever
needed framed or pre-parsed SSH over `alk/ssh`, that would be a new needed framed or pre-parsed SSH over `alk/ssh`, that would be a new
ALPN + new ADR, not a reinterpretation. ALPN + new ADR, not a reinterpretation.
- **Neutral**: `SshAdapter::handle` mounts the full russh server - **Neutral**: `SshAdapter::handle` mounts the full russh server
stack per stream, so endpoints pay per-connection bounded-resource stack per stream, so endpoints pay per-connection bounded-resource
limits (window, auth-attempt, inactivity ceilings) — the alkssh limits (window, auth-attempt, inactivity ceilings); the alkssh
limits contract applies per `alk/ssh` stream, not per transport. limits contract applies per `alk/ssh` stream, not per transport.
## References ## References
- Family ADR-003 (`003-alkssh-russh-adapter.md`) — decisions 1 and 4 - Family ADR-003 (`003-alkssh-russh-adapter.md`), decisions 1 and 4
- alkcall ADR-002 (ProtocolHandler ALPN claim), ADR-004 - alkcall ADR-002 (ProtocolHandler ALPN claim), ADR-004
(the `alk/ssh` → `SshAdapter` reservation), ADR-009 (browser-WASM (the `alk/ssh` → `SshAdapter` reservation), ADR-009 (browser-WASM
sketch), ADR-034 (`alk/ssh` stream_types `[0, 1]`), ADR-042/051 sketch), ADR-034 (`alk/ssh` stream_types `[0, 1]`), ADR-042/051
@@ -107,5 +107,5 @@ later.
- `docs/research/mapping-table.md` §topology, - `docs/research/mapping-table.md` §topology,
`docs/research/vision.md` scope Q1, `docs/research/vision.md` scope Q1,
`docs/research/poc-3-mapping-bakeoff.md` (edge termination, proven) `docs/research/poc-3-mapping-bakeoff.md` (edge termination, proven)
- OQ-01 (resolved — no consumer in scope; this ADR is the entire - OQ-01 (resolved: no consumer in scope; this ADR is the entire
decision surface) decision surface)
+20 -20
View File
@@ -3,16 +3,16 @@ status: reviewed
last_updated: 2026-10-28 last_updated: 2026-10-28
--- ---
# ADR-002: The russh pin — 0.65.0, aws-lc-rs, no compression, ed25519-only # ADR-002: The russh pin (0.65.0, aws-lc-rs, no compression, ed25519-only)
## Status ## Status
**Accepted** — 2026-10-26. A one-way door in practice (SSH negotiation **Accepted** (2026-10-26). A one-way door in practice (SSH negotiation
behavior crosses crate boundaries; bumps are deliberate, tested, and behavior crosses crate boundaries; bumps are deliberate, tested, and
recorded per the repo's wire-stability convention). Amended same day recorded per the repo's wire-stability convention). Amended same day
after review: the earlier enabling of the `rsa` feature is reverted after review: the earlier enabling of the `rsa` feature is reverted
(`rsa` off). Amended 2026-10-27 (review pass): the key-algorithm (`rsa` off). Amended 2026-10-27 (review pass): the key-algorithm
position is **ed25519-only** — the family's posture is the binding position is **ed25519-only**; the family's posture is the binding
constraint, not fleet compatibility (decision 4). constraint, not fleet compatibility (decision 4).
## Context ## Context
@@ -21,9 +21,9 @@ constraint, not fleet compatibility (decision 4).
2026-10-26 re-verification section carries the current facts. The POCs 2026-10-26 re-verification section carries the current facts. The POCs
validated against 0.64.1 (then-newest) with the `ring` backend; since validated against 0.64.1 (then-newest) with the `ring` backend; since
then **0.65.0** has been published (2026-10-25) with six security then **0.65.0** has been published (2026-10-25) with six security
fixes — three in classes an internet-facing SSH door cares about fixes, three of them in classes an internet-facing SSH door cares
(client EXT_INFO unbounded allocation, peer zero-max-packet-size about (client EXT_INFO unbounded allocation, peer zero-max-packet-size
channel stall, pre-auth nested-certificate stack exhaustion) — and two channel stall, pre-auth nested-certificate stack exhaustion), and two
breaking changes confined to handler-signature shapes breaking changes confined to handler-signature shapes
(`PublicKeyOffer` / `VerifiedPublicKey`; `async-trait` feature (`PublicKeyOffer` / `VerifiedPublicKey`; `async-trait` feature
removed). removed).
@@ -31,9 +31,9 @@ removed).
The family's crypto-posture context: `alktls`, `alkhttp`, and The family's crypto-posture context: `alktls`, `alkhttp`, and
`alknet` all standardize on `rustls` with the **`aws_lc_rs`** feature; `alknet` all standardize on `rustls` with the **`aws_lc_rs`** feature;
no family crate links `ring`. The provider choice matters only as no family crate links `ring`. The provider choice matters only as
binary-composition hygiene — when alkforge's binary links alkssh binary-composition hygiene (when alkforge's binary links alkssh
*and* the family's rustls stack, one shared primitive library is the *and* the family's rustls stack, the goal is one shared primitive
goal — and has nothing to do with TLS on the SSH wire. library) and has nothing to do with TLS on the SSH wire.
## Decision ## Decision
@@ -51,22 +51,22 @@ goal — and has nothing to do with TLS on the SSH wire.
builds clean in ~50 s with no external provisioning (prebuilt builds clean in ~50 s with no external provisioning (prebuilt
bindings), tree `aws-lc-rs 1.18.2` / `aws-lc-sys 0.46.0`. The bindings), tree `aws-lc-rs 1.18.2` / `aws-lc-sys 0.46.0`. The
POCs' `ring` build is recorded as the fallback if a target POCs' `ring` build is recorded as the fallback if a target
platform ever fails to provision aws-lc — a deliberate, platform ever fails to provision aws-lc, a deliberate,
recorded deviation, never drift. recorded deviation, never drift.
3. **`flate2` off — no compression.** The POCs already ran 3. **No compression (`flate2` off).** The POCs already ran
compression-free and vanilla OpenSSH was untroubled; SSH compression-free and vanilla OpenSSH was untroubled; SSH
compression is largely pointless at modern link layers and is the compression is largely pointless at modern link layers and is the
CRIME-adjacent attack surface OpenSSH itself defaults off. CRIME-adjacent attack surface OpenSSH itself defaults off.
Bounded-resources and surface-reduction posture agree. Bounded-resources and surface-reduction posture agree.
4. **ed25519-only — `rsa` off, and no other scheme is supported.** 4. **ed25519-only (`rsa` off, and no other scheme is supported).**
Amended 2026-10-27 from the earlier "ed25519-first" wording after Amended 2026-10-27 from the earlier "ed25519-first" wording after
review: the position is ed25519-only. One key algorithm carries review: the position is ed25519-only. One key algorithm carries
the family's identity: the same ed25519 keys serve alktls's the family's identity: the same ed25519 keys serve alktls's
self-signed TLS certificates and iroh, and the downstream alknet self-signed TLS certificates and iroh, and the downstream alknet
rewrite rides ssh + ed25519, tcp + tls, quic + tls, and iroh — the rewrite rides ssh + ed25519, tcp + tls, quic + tls, and iroh, the
same ed25519 identity. The legacy/alternative SSH key schemes same ed25519 identity. The legacy/alternative SSH key schemes
(rsa, dsa; ecdsa variants and hardware-token sk variants share the (rsa, dsa; ecdsa variants and hardware-token sk variants share the
defect) are issued, stored, or derived nowhere in the family — defect) are issued, stored, or derived nowhere in the family, so
supporting them here would be a wart backed by nothing else. supporting them here would be a wart backed by nothing else.
Enabling `rsa` would additionally have pulled two RC-pinned deps Enabling `rsa` would additionally have pulled two RC-pinned deps
and made alkssh the family's only RSA-holding crate. Concretely: and made alkssh the family's only RSA-holding crate. Concretely:
@@ -75,15 +75,15 @@ goal — and has nothing to do with TLS on the SSH wire.
RC-free except `ssh-key =0.7.0-rc.11`, which russh exact-pins on RC-free except `ssh-key =0.7.0-rc.11`, which russh exact-pins on
the `russh::keys` path). Wire behavior is honest and fail-closed: the `russh::keys` path). Wire behavior is honest and fail-closed:
a client presenting a non-ed25519 key gets a clean a client presenting a non-ed25519 key gets a clean
`USERAUTH_FAILURE` — never a half-open session. Operational `USERAUTH_FAILURE`, never a half-open session. Operational
shape: alkssh implements no other scheme; an assembly registers shape: alkssh implements no other scheme; an assembly registers
ed25519 fingerprints only; and `auth_publickey` **declines ed25519 fingerprints only; and `auth_publickey` **declines
non-ed25519 keys explicitly** (ADR-004's explicit-over-inherited non-ed25519 keys explicitly** (ADR-004's explicit-over-inherited
pattern applied to the algorithm position, so the posture survives pattern applied to the algorithm position, so the posture survives
a pin bump that might otherwise accept more schemes). If a scheme a pin bump that might otherwise accept more schemes). If a scheme
ever matters, the amendment is one feature flag plus this ADR. ever matters, the amendment is one feature flag plus this ADR.
5. **`des` / `dsa` never.** russh's own "Danger: insecure" features — 5. **`des` / `dsa` never.** russh's own "Danger: insecure" features,
no alkssh configuration may enable them. which no alkssh configuration may enable.
6. **Manifest lands with the dependency**: `rust-version = "1.89"` 6. **Manifest lands with the dependency**: `rust-version = "1.89"`
(russh's MSRV; supersedes the current 1.88) and, with it, the (russh's MSRV; supersedes the current 1.88) and, with it, the
edition-2024 bump the POCs compiled under. edition-2024 bump the POCs compiled under.
@@ -91,12 +91,12 @@ goal — and has nothing to do with TLS on the SSH wire.
## Consequences ## Consequences
- **Positive**: the pin is current, carries the security fixes, and - **Positive**: the pin is current, carries the security fixes, and
matches the family crypto posture; the tree is fully inventoried — matches the family crypto posture; the tree is fully inventoried,
with `rsa` off it is **RC-free except `ssh-key =0.7.0-rc.11`** and with `rsa` off it is **RC-free except `ssh-key =0.7.0-rc.11`**
(unavoidable: russh exact-pins it on the `russh::keys` path); (unavoidable: russh exact-pins it on the `russh::keys` path);
MSRV/licensing are explicit. MSRV/licensing are explicit.
- **Negative**: clients presenting non-ed25519 keys (RSA among them) - **Negative**: clients presenting non-ed25519 keys (RSA among them)
decline outright (clean `USERAUTH_FAILURE`) — recorded as decline outright (clean `USERAUTH_FAILURE`), recorded as
intentional; `ssh-key`'s RC pin rides the russh keys path until intentional; `ssh-key`'s RC pin rides the russh keys path until
russh moves off it; aws-lc-rs is the heavier build (proven russh moves off it; aws-lc-rs is the heavier build (proven
acceptable here, with `ring` as the recorded fallback). acceptable here, with `ring` as the recorded fallback).
@@ -3,11 +3,11 @@ status: reviewed
last_updated: 2026-10-28 last_updated: 2026-10-28
--- ---
# ADR-003: The capability feature graph — flat, independent, two-direction # ADR-003: The capability feature graph (flat, independent, two-direction)
## Status ## Status
**Accepted** — 2026-10-26. Builds the fail-closed capability contract **Accepted** (2026-10-26). Builds the fail-closed capability contract
(family ADR-003 decision 3) into the crate's own feature names; the (family ADR-003 decision 3) into the crate's own feature names; the
decline paths are contract territory on every side of every feature. decline paths are contract territory on every side of every feature.
@@ -17,15 +17,15 @@ decline paths are contract territory on every side of every feature.
into capabilities by what rides it (shell/exec, git, pipes), and into capabilities by what rides it (shell/exec, git, pipes), and
vanilla SSH multiplexes tunnel legs through `direct-tcpip`/`forwarded-tcpip` vanilla SSH multiplexes tunnel legs through `direct-tcpip`/`forwarded-tcpip`
channels (a vanilla `ssh -D` is its own client-side SOCKS door plus channels (a vanilla `ssh -D` is its own client-side SOCKS door plus
`direct-tcpip` — the wire never sees SOCKS, POC-3 correction 2). The feature `direct-tcpip`; the wire never sees SOCKS, POC-3 correction 2). The feature
graph is how alkforge gets an endpoint that is *structurally incapable* graph is how alkforge gets an endpoint that is *structurally incapable*
of serving what it didn't enable: a git-only build accepts no shell and of serving what it didn't enable: a git-only build accepts no shell and
no tunnels — and, in the opposite direction, no capabilities a client no tunnels; and, in the opposite direction, no capabilities a client
build didn't opt into. build didn't opt into.
Phase 0 validated the seams end to end (POC-3 stages A–D: git exec, Phase 0 validated the seams end to end (POC-3 stages A–D: git exec,
direct-tcpip via the alktunnels bridge, session/pty, all against direct-tcpip via the alktunnels bridge, session/pty, all against
published sibling crates — with stage B additionally establishing, published sibling crates, with stage B additionally establishing,
against real vanilla `ssh -D` + curl, that the wire-level SOCKS story against real vanilla `ssh -D` + curl, that the wire-level SOCKS story
is client-side; its SOCKS-in-channel serve path was subsequently is client-side; its SOCKS-in-channel serve path was subsequently
retired, see §1a), and the user's direction confirmed the scope: full retired, see §1a), and the user's direction confirmed the scope: full
@@ -40,53 +40,52 @@ feature set except sftp (placeholder until alkfs lands).
|---|---|---|---| |---|---|---|---|
| `session` | session channels: pty/shell, pipe-mode exec | client pty/shell/exec/subsystem opens | alktty | | `session` | session channels: pty/shell, pipe-mode exec | client pty/shell/exec/subsystem opens | alktty |
| `git` | git-parseable exec (dispatched out of session channels) | client git consumption of upstream servers | alkgit | | `git` | git-parseable exec (dispatched out of session channels) | client git consumption of upstream servers | alkgit |
| `tunnel` | `direct-tcpip`, `forwarded-tcpip`, `direct-streamlocal`, the `tcpip_forward`/`streamlocal_forward` listen flow (the streamlocal rows — channel and listen — are runtime-Unix-gated, OQ-11) | client `direct-tcpip` opens (which is what `-D` rides — see 1a) | alktunnels | | `tunnel` | `direct-tcpip`, `forwarded-tcpip`, `direct-streamlocal`, the `tcpip_forward`/`streamlocal_forward` listen flow (the streamlocal rows, channel and listen, are runtime-Unix-gated, OQ-11) | client `direct-tcpip` opens (which is what `-D` rides; see 1a) | alktunnels |
| `sftp` | *placeholder — declines until alkfs lands (OQ-13 resolved)* | client sftp half, same placeholder | alkfs (future) | | `sftp` | *placeholder, declines until alkfs lands (OQ-13 resolved)* | client sftp half, same placeholder | alkfs (future) |
**1a. No `dynamic-forward` feature.** Amended 2026-10-26 (review **1a. No `dynamic-forward` feature.** Amended 2026-10-26 (review
untangle): the earlier graph carried a `dynamic-forward` feature untangle): the earlier graph carried a `dynamic-forward` feature
with a server-side SOCKS5-in-channel row — a family-native client with a server-side SOCKS5-in-channel row, a family-native client
shape ("speak SOCKS into a session channel, server dials through shape ("speak SOCKS into a session channel, server dials through
alksocks"). No consumer of ours is that client: vanilla `ssh -D` alksocks"). No consumer of ours is that client: vanilla `ssh -D`
does its SOCKS5 handshake client-side and opens plain does its SOCKS5 handshake client-side and opens plain
`direct-tcpip` channels (POC-3 correction 2), and the family `direct-tcpip` channels (POC-3 correction 2), and the family
behavior of `-D` is a **client-side local door** — alksocks' local behavior of `-D` is a **client-side local door**: alksocks' local
door given a `DialFn` backed by the alkssh client's door given a `DialFn` backed by the alkssh client's
`direct-tcpip` opens, composed at the assembly. The server needs `direct-tcpip` opens, composed at the assembly. The server needs
only the `tunnel` row for the whole `-D` leg, and alkssh does not only the `tunnel` row for the whole `-D` leg, and alkssh does not
depend on alksocks on this path. The SSH-detect dispatch from depend on alksocks on this path. The SSH-detect dispatch from
ADR-006 point 3 retires with it (session dispatch is parse-only; ADR-006 point 3 retires with it (session dispatch is parse-only;
a request-less session channel declines). If a consumer ever a request-less session channel declines). If a consumer ever
genuinely wants a remote SOCKS door (the OQ-01 relay/browser leg — genuinely wants a remote SOCKS door (the OQ-01 relay/browser leg,
resolved out of scope: no such consumer exists today), adding it resolved out of scope: no such consumer exists today), adding it
back is an explicit ADR — back is an explicit ADR. The two transports are "just byte
the two transports are "just byte streams", so nothing forbids streams", so nothing forbids it; nothing needs it.
it; nothing needs it.
2. **Flat and independent.** No feature implies another. `default = []` 2. **Flat and independent.** No feature implies another. `default = []`:
— a default build has no capabilities at all; every unfeatured a default build has no capabilities at all; every unfeatured
channel type/open/request declines with a clean error. `git` does channel type/open/request declines with a clean error. `git` does
not imply `session` (a git-only build serves the git-parseable exec not imply `session` (a git-only build serves the git-parseable exec
path by declining to parse to anything else — the parse-then-fail path by declining to parse to anything else, the parse-then-fail
contract from POC-3 stage A). Note the `tunnel` client leg's SOCKS contract from POC-3 stage A). Note the `tunnel` client leg's SOCKS
door is *local to the dialing host* (alksocks composed at the door is *local to the dialing host* (alksocks composed at the
assembly), not a wire capability — the wire sees `direct-tcpip` assembly), not a wire capability; the wire sees `direct-tcpip`
only. only.
3. **Two-direction symmetry.** Each feature name gates *both* the 3. **Two-direction symmetry.** Each feature name gates *both* the
server-direction (what an embedded alkssh serves vanilla clients) server-direction (what an embedded alkssh serves vanilla clients)
and the client-direction (what a family app drives when dialing an and the client-direction (what a family app drives when dialing an
upstream SSH server, including the forwarded-channel acceptor rows). upstream SSH server, including the forwarded-channel acceptor rows).
One name, one capability, both doors — this is the symmetry rule, One name, one capability, both doors: this is the symmetry rule,
now structural: a `git`-only build cannot dial a tunnel upstream now structural: a `git`-only build cannot dial a tunnel upstream
any more than it can serve one. any more than it can serve one.
4. **Client-side fail-closed at the acceptor.** Server-initiated 4. **Client-side fail-closed at the acceptor.** Server-initiated
channel opens (`forwarded-tcpip` is the real one — the `-R` data channel opens (the real one is `forwarded-tcpip`, the `-R` data
path) are served only under a registered feature's acceptor path) are served only under a registered feature's acceptor
handler; unhandled/unfeatured accepted opens are **declined**, not handler; unhandled/unfeatured accepted opens are **declined**, not
accept-and-dropped (russh's client default is accept-then-drop — accept-and-dropped (russh's client default is accept-then-drop,
ours to fix; the reverse-runner extraction names it). ours to fix; the reverse-runner extraction names it).
5. **Never features.** `x11` and `auth-agent` forwarding are not 5. **Never features.** `x11` and `auth-agent` forwarding are not
feature-gated at all — always declined, at any configuration feature-gated at all and are always declined, at any configuration
(agent forwarding is the *anti-pattern* of the alkvault custody (agent forwarding is the *anti-pattern* of the alkvault custody
model; x11 has no family seam). An ADR amendment is required to model; x11 has no family seam). An ADR amendment is required to
ever feature-gate them. ever feature-gate them.
@@ -104,21 +103,21 @@ feature set except sftp (placeholder until alkfs lands).
surfaces can't drift apart on capability naming. surfaces can't drift apart on capability naming.
- **Negative**: the split of `session` makes feature interplay a - **Negative**: the split of `session` makes feature interplay a
real test matrix (per-feature decline paths on both directions are real test matrix (per-feature decline paths on both directions are
contract tests — the AGENTS.md verification convention covers this contract tests; the AGENTS.md verification convention covers this
via `--all-features` runs plus the decline-matrix suite). via `--all-features` runs plus the decline-matrix suite).
- **Neutral**: feature *names* are now contractual (downstream builds - **Neutral**: feature *names* are now contractual (downstream builds
specify them); renames would be semver-visible — hence the ADR, not specify them); renames would be semver-visible, hence the ADR rather
incidental naming. than incidental naming.
## References ## References
- `mapping-table.md` (the validated rows under these names + the - `mapping-table.md` (the validated rows under these names + the
feature-split notes + the three POC-3 corrections) feature-split notes + the three POC-3 corrections)
- Family ADR-003 (decision 3 — the fail-closed capability features), - Family ADR-003 (decision 3, the fail-closed capability features),
family ADR-002 (why exclusion matters for alkforge) family ADR-002 (why exclusion matters for alkforge)
- `docs/research/reverse-runner.md` (client-direction surface the - `docs/research/reverse-runner.md` (client-direction surface the
symmetry rule serves; the acceptor default fact) symmetry rule serves; the acceptor default fact)
- `docs/research/poc-3-mapping-bakeoff.md` (stages A–D: the seams at - `docs/research/poc-3-mapping-bakeoff.md` (stages A–D: the seams at
these names work end to end) these names work end to end)
- OQ-13 (resolved — the placeholder contract stands), OQ-02 (resolved - OQ-13 (resolved; the placeholder contract stands), OQ-02 (resolved
by ADR-006) by ADR-006)
@@ -3,16 +3,16 @@ status: reviewed
last_updated: 2026-10-28 last_updated: 2026-10-28
--- ---
# ADR-004: Auth dispositions — publickey-only, fail-closed, fingerprint identity # ADR-004: Auth dispositions (publickey-only, fail-closed, fingerprint identity)
## Status ## Status
**Accepted** — 2026-10-26. Evidence-backed (POC-1 end to end, **Accepted** (2026-10-26). Evidence-backed (POC-1 end to end,
POC-2's signature-pipeline facts); open to amendment only via a new POC-2's signature-pipeline facts); open to amendment only via a new
ADR. Amended 2026-10-27 (review pass, per ADR-002 decision 4's ADR. Amended 2026-10-27 (review pass, per ADR-002 decision 4's
ed25519-only amendment): `auth_publickey` additionally declines ed25519-only amendment): `auth_publickey` additionally declines
non-ed25519 keys explicitly — the key-algorithm posture, not the non-ed25519 keys explicitly (the key-algorithm posture, not the
fingerprint disposition, changes; all other dispositions stand. Not a fingerprint disposition, changes); all other dispositions stand. Not a
ledger item (no wire format changes), but the decline ledger item (no wire format changes), but the decline
matrix is contract territory: every decline is tested on both sides of matrix is contract territory: every decline is tested on both sides of
every feature. every feature.
@@ -22,7 +22,7 @@ every feature.
The family identity model has exactly one SSH-shaped resolution path: The family identity model has exactly one SSH-shaped resolution path:
a verified key's fingerprint → alkcall `IdentityProvider:: a verified key's fingerprint → alkcall `IdentityProvider::
resolve_from_fingerprint` → `Identity` (`auth-bridge.md`). russh 0.64.1 resolve_from_fingerprint` → `Identity` (`auth-bridge.md`). russh 0.64.1
(the POC pin) performs the cryptographic verification itself — (the POC pin) performs the cryptographic verification itself, so
`auth_publickey` fires *after* signature and ownership verification `auth_publickey` fires *after* signature and ownership verification
(POC-1 observed), and invalid/malformed signatures produce (POC-1 observed), and invalid/malformed signatures produce
`USERAUTH_FAILURE` before any handler hook fires (POC-2); the facts `USERAUTH_FAILURE` before any handler hook fires (POC-2); the facts
@@ -35,25 +35,25 @@ twice: which methods exist, and what unauthenticated attempts receive.
## Decision ## Decision
1. **`auth_publickey` is implemented — the only method.** Handler: 1. **`auth_publickey` is implemented, and it is the only method.** Handler:
fingerprint (`ssh-key` re-export, `HashAlg::Sha256`, fingerprint (`ssh-key` re-export, `HashAlg::Sha256`,
`SHA256:` + unpadded base64 over the standard alphabet — `SHA256:` + unpadded base64 over the standard alphabet,
`+/`, not the url-safe alphabet; byte-identical to `+/`, not the url-safe alphabet; byte-identical to
`ssh-keygen -lf`) → `ssh-keygen -lf`) →
`resolve_from_fingerprint` → `Some` → accept with the resolved `resolve_from_fingerprint` → `Some` → accept with the resolved
identity; `None` → reject. The fingerprint *string format* is a identity; `None` → reject. The fingerprint *string format* is a
contract invariant: registration strings from any standard issuer contract invariant: registration strings from any standard issuer
(ssh-keygen, alkcall-side computation) must compare equal — (ssh-keygen, alkcall-side computation) must compare equal; POC-1
POC-1 proved parity against `ssh-keygen -lf` output. proved parity against `ssh-keygen -lf` output.
2. **`auth_publickey_offered` is accepted-and-delegated.** The probe 2. **`auth_publickey_offered` is accepted-and-delegated.** The probe
hook (key seen, ownership unverified) accepts the offer and lets hook (key seen, ownership unverified) accepts the offer and lets
`auth_publickey` decide — which keeps `max_auth_attempts` counting `auth_publickey` decide, which keeps `max_auth_attempts` counting
honest (confirmed: the hook does not consume an attempt). honest (confirmed: the hook does not consume an attempt).
3. **Every other method is explicitly declined, including when inert 3. **Every other method is explicitly declined, including when inert
at the pin.** `auth_none`, `auth_password`, at the pin.** `auth_none`, `auth_password`,
`auth_keyboard_interactive`, `auth_openssh_certificate` each get `auth_keyboard_interactive`, `auth_openssh_certificate` each get
explicit reject implementations. At russh 0.64.1 the defaults explicit reject implementations. At russh 0.64.1 the defaults
already reject (POC-1's correction of the 0.5x-era assumption) — already reject (POC-1's correction of the 0.5x-era assumption);
the explicit implementations are family contract, not redundancy: the explicit implementations are family contract, not redundancy:
the pin is a one-way door and a future bump could silently change the pin is a one-way door and a future bump could silently change
defaults (an auth-posture change would otherwise be invisible). defaults (an auth-posture change would otherwise be invisible).
@@ -86,7 +86,7 @@ twice: which methods exist, and what unauthenticated attempts receive.
small and fully testable per build; auth posture survives a russh small and fully testable per build; auth posture survives a russh
bump that changes defaults. bump that changes defaults.
- **Negative**: none material. The explicit-declines cost a few - **Negative**: none material. The explicit-declines cost a few
handler lines per method — deliberately purchased immovability. handler lines per method, deliberately purchased immovability.
- **Neutral**: password/keyboard-interactive exist client-side only as - **Neutral**: password/keyboard-interactive exist client-side only as
non-goals (`vision.md`) until a consuming use case arrives; the non-goals (`vision.md`) until a consuming use case arrives; the
server never gains them without amending this ADR (a wire-visible server never gains them without amending this ADR (a wire-visible
@@ -3,11 +3,11 @@ status: reviewed
last_updated: 2026-10-28 last_updated: 2026-10-28
--- ---
# ADR-005: At-rest key custody — vault-wrapped OpenSSH-PEM, decrypt-at-use # ADR-005: At-rest key custody (vault-wrapped OpenSSH-PEM, decrypt-at-use)
## Status ## Status
**Accepted** — 2026-10-26. Evidence-backed (POC-2, 39/39, including **Accepted** (2026-10-26). Evidence-backed (POC-2, 39/39, including
the 4-way inner-format bake and the failure matrix); amendable only the 4-way inner-format bake and the failure matrix); amendable only
via a new ADR. via a new ADR.
@@ -16,7 +16,7 @@ via a new ADR.
On the client direction alkssh *holds* secret material (dial keys); on On the client direction alkssh *holds* secret material (dial keys); on
the server direction it holds the host key. Family rules in force: the server direction it holds the host key. Family rules in force:
- Secrets come from alkvault-backed sources — never env vars, never - Secrets come from alkvault-backed sources, never env vars, never
plaintext at rest (family ADR-002 decision 5; family-wide standing plaintext at rest (family ADR-002 decision 5; family-wide standing
posture). posture).
- The vault is a **secret store on this path, not a key generator or - The vault is a **secret store on this path, not a key generator or
@@ -24,12 +24,13 @@ the server direction it holds the host key. Family rules in force:
a different feature and is not the SSH-keys path (`auth-bridge.md` a different feature and is not the SSH-keys path (`auth-bridge.md`
§The gap). Held SSH keys are bearer-token-class secrets: opaque §The gap). Held SSH keys are bearer-token-class secrets: opaque
bytes, vault-encrypted at rest, decrypted at use. bytes, vault-encrypted at rest, decrypted at use.
- The vault is **never** involved in resolving *clients'* fingerprints - The vault is **never** involved in resolving *clients'* fingerprints;
— that path is the assembly-layer `IdentityProvider` (scope note in that path is the assembly-layer `IdentityProvider` (scope note in
`pocs.md` §POC-2). `pocs.md` §POC-2).
alkvault 0.1.0's `encrypt`/`decrypt` surface is `&str` in, `String` alkvault's `encrypt`/`decrypt` surface is `&str` in, `Zeroizing<String>`
out (`EncryptedData` as the wrapper) — a decisive constraint on any out since 0.2.0 (0.1.0 returned a plain `String`; the `EncryptedData`
wrapper unchanged), a decisive constraint on any
binary inner format (POC-2 measured: it would force base64 layers on binary inner format (POC-2 measured: it would force base64 layers on
both ends). both ends).
@@ -42,9 +43,9 @@ both ends).
boundary (`ssh -i` accepted the decrypted bytes directly), and boundary (`ssh -i` accepted the decrypted bytes directly), and
exactly one decode call (`decode_secret_key(plain, None)`) from exactly one decode call (`decode_secret_key(plain, None)`) from
russh. Rejected alternatives, recorded for the trail: ssh-key's russh. Rejected alternatives, recorded for the trail: ssh-key's
own encrypted-key format (double-wrapping antipattern — the own encrypted-key format (double-wrapping antipattern: the
passphrase is itself a stored secret, moving the problem one level passphrase is itself a stored secret, moving the problem one level
down), raw-seed base64 (80 B vs 540 B blobs — legitimate size win, down), raw-seed base64 (80 B vs 540 B blobs, a legitimate size win,
rejected because it couples storage to a fixed key shape; "the rejected because it couples storage to a fixed key shape; "the
bytes of a PEM file" survives every algorithm ssh-key can decode), bytes of a PEM file" survives every algorithm ssh-key can decode),
PKCS#8 PEM (parity, loses the native round-trip, adds nothing). PKCS#8 PEM (parity, loses the native round-trip, adds nothing).
@@ -55,20 +56,28 @@ both ends).
only at dial time: locking it mid-session keeps live sessions only at dial time: locking it mid-session keeps live sessions
streaming while new dials fail (POC-2 verified); unlock is streaming while new dials fail (POC-2 verified); unlock is
side-effect-free on key identity. side-effect-free on key identity.
3. **Zeroization ownership (the known gap).** ssh-key zeroizes 3. **Zeroization ownership (the gap, closed upstream).** ssh-key
ed25519 key bytes on `Drop` (decoded keys, and their clones, clean zeroizes ed25519 key bytes on `Drop` (decoded keys, and their
themselves). The *decrypted plaintext* is the gap: alkvault's clones, clean themselves). The *decrypted plaintext* was the gap:
`decrypt` returns a plain `String`, so from the vault's return to alkvault's `decrypt` once returned a plain `String`, leaving the
the adapter's wrap the plaintext sits in unfenced heap. The plaintext in unfenced heap between the vault's return and the
adapter owns the wrap (proven in POC-2) — **that is the contract on adapter's wrap; the adapter owned the wrap then (proven in POC-2;
this path**; the vault-side improvement (a `Zeroizing<String>`-out **that was the contract on this path**) and the vault-side
API) is an upstream ask filed in alkvault's own tracker, not an improvement (a `Zeroizing<String>`-out API) was an upstream ask
alkssh obligation — when it lands, this ADR's pointer updates as a filed in alkvault's own tracker. The ask shipped in **alkvault
one-line doc touch (alkssh OQ-07 is closed at the 0.2.0** (2026-11-04, the tracker's review 001; this ADR is the
adapter-convention level). origin it cites): `decrypt` returns `Zeroizing<String>`, the buffer
zeroizes on every path (including the UTF-8 error path), and
`rotate` transits zeroized output too. The adapter seam wrap is
thereby **dropped** (the alkssh 0.2.0 adoption): `decrypt_and_decode`
consumes the vault's zeroized return directly, since the double wrap
would be dead posture. Decision 3's ownership now sits entirely
with alkvault; the adapter's obligation shrinks to consuming the
zeroized value in one pass (the `decode_key_arm` signature keeps
riding the `Zeroizing<String>` type as the proof).
4. **Failure modes are all fail-closed, with distinct error shapes.** 4. **Failure modes are all fail-closed, with distinct error shapes.**
Locked vault → no dial (and no server: a host key that can't load Locked vault → no dial (and no server: a host key that can't load
prevents server construction — verified at bootstrap). Absent prevents server construction, verified at bootstrap). Absent
blob → no dial. Wrong mnemonic / tampered ciphertext → decryption blob → no dial. Wrong mnemonic / tampered ciphertext → decryption
failure, no dial. Unknown `key_version` → vault's `InvalidPath` failure, no dial. Unknown `key_version` → vault's `InvalidPath`
refuse-before-crypto. Rotation (at-rest version bump, key refuse-before-crypto. Rotation (at-rest version bump, key
@@ -76,25 +85,25 @@ both ends).
each to a distinct variant; the vault's error taxonomy is the each to a distinct variant; the vault's error taxonomy is the
adapter's to reshape, never to collapse. adapter's to reshape, never to collapse.
5. **`EncryptedData` is opaque to alkssh.** The crate never 5. **`EncryptedData` is opaque to alkssh.** The crate never
interprets `key_version` or any record internals — the vault owns interprets `key_version` or any record internals; the vault owns
version semantics. Rotation is a storage-side operation. version semantics. Rotation is a storage-side operation.
6. **Host keys ride the same model.** Server host keys are 6. **Host keys ride the same model.** Server host keys are
decrypt-at-construction into `Config::keys` through the same decrypt-at-construction into `Config::keys` through the same
load path; the client's host-*pin* is public config, never vault load path; the client's host-*pin* is public config, never vault
material (OQ-05's two-case policy handles pinning). The two-case material (OQ-05's two-case policy handles pinning). The two-case
host-key policy *seam* is the crate's trust-decision contract — host-key policy *seam* is the crate's trust-decision contract: the
the crate is policy-blind (fail-closed default; a permissive crate is policy-blind (fail-closed default; a permissive
case-2 policy is an assembly choice) — the contract lives in case-2 policy is an assembly choice), and the contract lives in
`auth.md`/`client.md`. `auth.md`/`client.md`.
7. **Plaintext never persists.** No disk spill on this path, ever; 7. **Plaintext never persists.** No disk spill on this path, ever;
the vanilla-tool PEM compatibility is for the assembly layer to the vanilla-tool PEM compatibility is for the assembly layer to
exploit if ever (e.g. handing a key to an external agent process — exploit if ever (e.g. handing a key to an external agent process,
itself out of family scope). itself out of family scope).
8. **The `Signer` seam is dismissed, with evidence.** POC-2's verdict: 8. **The `Signer` seam is dismissed, with evidence.** POC-2's verdict:
no `impl Signer` exists anywhere in the 0.64.1 tree (the doc no `impl Signer` exists anywhere in the 0.64.1 tree (the doc
comment claiming an SSH-agent implementation is stale); a comment claiming an SSH-agent implementation is stale); a
hand-rolled signer must replicate the splice convention (`to_sign hand-rolled signer must replicate the splice convention (`to_sign
|| string{sig}` — initial omission was silently misparsed); an || string{sig}`; an initial omission was silently misparsed); an
in-process vault gains nothing over one-time decode (decrypted in-process vault gains nothing over one-time decode (decrypted
bytes exist client-side at *either* dial time or sign time; bytes exist client-side at *either* dial time or sign time;
decrypt-per-sign keeps *more* plaintext copies alive while decrypt-per-sign keeps *more* plaintext copies alive while
@@ -107,10 +116,11 @@ both ends).
future held material; every failure mode is proven fail-closed; the future held material; every failure mode is proven fail-closed; the
at-rest bytes are plain OpenSSH key files, so alkvault stores and at-rest bytes are plain OpenSSH key files, so alkvault stores and
rotates them with no SSH-specific logic. rotates them with no SSH-specific logic.
- **Negative**: decrypted plaintext has one unfenced window (before - **Negative**: ~~decrypted plaintext has one unfenced window (before
the adapter's wrap) until alkvault changes its API — the gap is the adapter's wrap) until alkvault changes its API~~; the gap is
recorded, owned, and filed with its owner (the alkvault tracker; resolved (alkvault 0.2.0's `Zeroizing<String>` return; the adapter
OQ-07 resolved at the adapter level), not silently absorbed. wrap dropped, OQ-07 amended). No plaintext window remains on this
ADR's path.
- **Neutral**: the seed-variant size optimization remains noted as a - **Neutral**: the seed-variant size optimization remains noted as a
legitimate future option if blob size ever matters; adopting it legitimate future option if blob size ever matters; adopting it
would be an ADR amendment (it changes what a stored record is). would be an ADR amendment (it changes what a stored record is).
@@ -122,6 +132,6 @@ both ends).
lifecycle + zeroization bounds, §3 the failure matrix, §4 the lifecycle + zeroization bounds, §3 the failure matrix, §4 the
Signer dismissal, §5 host keys end to end) Signer dismissal, §5 host keys end to end)
- alkcall ADR-010 + alkhttp ADR-014 (the secret-material-flow family - alkcall ADR-010 + alkhttp ADR-014 (the secret-material-flow family
pattern — ADR-010 is alkhttp ADR-014's alkcall analog) pattern; ADR-010 is alkhttp ADR-014's alkcall analog)
- Family ADR-002 decision 5, family ADR-003 decision 2 - Family ADR-002 decision 5, family ADR-003 decision 2
- OQ-05, OQ-06, OQ-07, OQ-13 (all resolved) - OQ-05, OQ-06, OQ-07, OQ-13 (all resolved)
@@ -3,14 +3,14 @@ status: reviewed
last_updated: 2026-10-28 last_updated: 2026-10-28
--- ---
# ADR-006: Mapping decisions — route-1 bridge, exec-only git, session dispatch # ADR-006: Mapping decisions (route-1 bridge, exec-only git, session dispatch)
## Status ## Status
**Accepted** — 2026-10-26. All three decisions are **Accepted** (2026-10-26). All three decisions are
POC-3-validated against published sibling crates; amendments are new POC-3-validated against published sibling crates; amendments are new
ADRs. Amended same day (review untangle): point 3's **SOCKS-detect is ADRs. Amended same day (review untangle): point 3's **SOCKS-detect is
retired** — with the `dynamic-forward` feature removed (ADR-003 §1a), retired**: with the `dynamic-forward` feature removed (ADR-003 §1a),
session dispatch is parse-only and a request-less session channel session dispatch is parse-only and a request-less session channel
simply declines. The parse-vs-anything split was only ever needed if simply declines. The parse-vs-anything split was only ever needed if
a session channel could *be* a SOCKS stream; no consumer is that a session channel could *be* a SOCKS stream; no consumer is that
@@ -39,48 +39,48 @@ alkgit/alksocks/alktunnels/alktty).
(`channel_open_direct_tcpip` → accept only after the fabric open (`channel_open_direct_tcpip` → accept only after the fabric open
succeeds → `copy bidirectional`). POC-3 stage C proved it end to succeeds → `copy bidirectional`). POC-3 stage C proved it end to
end (8 MiB in ~333 ms; fabric-open failure maps to an explicit end (8 MiB in ~333 ms; fabric-open failure maps to an explicit
SSH `ConnectFailed` reject — accept happens only post-success, SSH `ConnectFailed` reject; accept happens only post-success,
since `accept()` consumes the handle). Registry-side bridging since `accept()` consumes the handle). Registry-side bridging
(route 2: alkssh-shaped `ChannelOperations` in a serving (route 2: alkssh-shaped `ChannelOperations` in a serving
endpoint's registry) is **rejected**: it inverts the door posture endpoint's registry) is **rejected**: it inverts the door posture
by putting adapter work upstream of the door. The alktunnels by putting adapter work upstream of the door. The alktunnels
*absence* note (no serve-over-stream entry) is thereby resolved — *absence* note (no serve-over-stream entry) is thereby resolved,
route 1 never needed one. as route 1 never needed one.
2. **Git over SSH is exec-only; every subsystem request declines.** 2. **Git over SSH is exec-only; every subsystem request declines.**
The vanilla git connection model is: git spawns its ssh transport, The vanilla git connection model is: git spawns its ssh transport,
which opens one session channel and sends which opens one session channel and sends
`exec_request "git-{upload,receive}-pack '/repo'"` — the repo path `exec_request "git-{upload,receive}-pack '/repo'"`, so the repo path
rides inside the command string, so a subsystem request (name rides inside the command string, and a subsystem request (name
only, no repo id) can never dispatch. POC-3 proved the parse only, no repo id) can never dispatch. POC-3 proved the parse
grammar (quoted/unquoted paths, service-only → decline, grammar (quoted/unquoted paths, service-only → decline,
non-git → parse-then-fail with no resource alloc) and non-git → parse-then-fail with no resource alloc) and
clone/push/second-push through the published seam with zero clone/push/second-push through the published seam with zero
fixups. Vanilla git behavior loses nothing — vanilla git never fixups. Vanilla git behavior loses nothing: vanilla git never
speaks subsystem for git; the decline only refuses a manual speaks subsystem for git; the decline only refuses a manual
`ssh -s`-style invocation, with a reason. `ssh -s`-style invocation, with a reason.
3. **~~Session-channel dispatch: request-driven parse wins; detect 3. **~~Session-channel dispatch: request-driven parse wins; detect
covers the request-less case~~ — amended: parse-only.** The covers the request-less case~~ (amended: parse-only).** The
original decision composed the POC-3 modes as: requests take the original decision composed the POC-3 modes as: requests take the
parse path (git under `git`, pipe/shell under `session`); parse path (git under `git`, pipe/shell under `session`);
request-less session channels ran a SOCKS-detect request-less session channels ran a SOCKS-detect
(`dynamic-forward`: spawn, first-byte `0x05` sniff, 500 ms (`dynamic-forward`: spawn, first-byte `0x05` sniff, 500 ms
bounded window). With `dynamic-forward` removed from the feature bounded window). With `dynamic-forward` removed from the feature
graph (ADR-003 §1a), the detect has no owner: **session dispatch graph (ADR-003 §1a), the detect has no owner: **session dispatch
is parse-only** — requests parse to git (exec) or pipe/shell, is parse-only**: requests parse to git (exec) or pipe/shell,
and a request-less session channel declines as unfeatured. This and a request-less session channel declines as unfeatured. This
still resolves OQ-02 (the composition was the unknown; the still resolves OQ-02 (the composition was the unknown; the
resolution is now the simpler of the two candidates). resolution is now the simpler of the two candidates).
4. **The spawn-and-return rule is a structural constraint, not an 4. **The spawn-and-return rule is a structural constraint, not an
implementation detail.** russh 0.64.1-era servers run handler implementation detail.** russh 0.64.1-era servers run handler
callbacks inline inside the session's packet loop — a blocking callbacks inline inside the session's packet loop, so a blocking
handler deadlocks against the peer's follow-up bytes (POC-3 handler deadlocks against the peer's follow-up bytes (POC-3
proved by construction: receive-pack pushes bytes immediately proved by construction: receive-pack pushes bytes immediately
after the exec request). Every serving path is after the exec request). Every serving path is
`tokio::spawn` + handler-returns-fast; exit reporting from the `tokio::spawn` + handler-returns-fast; exit reporting from the
spawned task rides the cloned `Session::handle()` — spawned task rides the cloned `Session::handle()`:
`exit-status`(then `close`) before channel close, `exit-signal` `exit-status` (then `close`) before channel close, `exit-signal`
for signal termination (signal-name fidelity: the derivation for signal termination (signal-name fidelity: the derivation
contract on `mapping.md`'s exit rows — OQ-10 resolved). contract on `mapping.md`'s exit rows; OQ-10 resolved).
## Consequences ## Consequences
@@ -89,11 +89,11 @@ alkgit/alksocks/alktunnels/alktty).
alkforge assembly gets an exact wiring recipe (POC-3 §C.4's alkforge assembly gets an exact wiring recipe (POC-3 §C.4's
in-process fabric leg is the template, verbatim). in-process fabric leg is the template, verbatim).
- **Negative**: the SOCKS-detect retirement removes a tunable the - **Negative**: the SOCKS-detect retirement removes a tunable the
POC proved workable — if a family client ever wants a remote SOCKS POC proved workable; if a family client ever wants a remote SOCKS
door, the shape is re-addable via ADR (500 ms bounded window door, the shape is re-addable via ADR (500 ms bounded window
preserved in the reference here). preserved in the reference here).
- **Neutral**: route 2 remains documented in `mapping-table.md` as - **Neutral**: route 2 remains documented in `mapping-table.md` as
the rejected alternative, with its rationale — useful if the the rejected alternative, with its rationale, useful if the
posture is ever revisited (that revisit is an ADR, not a code flip). posture is ever revisited (that revisit is an ADR, not a code flip).
## References ## References
@@ -103,8 +103,8 @@ alkgit/alksocks/alktunnels/alktty).
- `mapping-table.md` (the rows + the absence note this ADR resolves) - `mapping-table.md` (the rows + the absence note this ADR resolves)
- ADR-003 (the feature gates these dispatch decisions sit behind) - ADR-003 (the feature gates these dispatch decisions sit behind)
- `docs/research/reverse-runner.md` (the client-direction mirror of - `docs/research/reverse-runner.md` (the client-direction mirror of
leg 1 — the `-L`/`-R` legs the same route-1 bridge serves) leg 1, the `-L`/`-R` legs the same route-1 bridge serves)
- OQ-02 (resolved by this ADR), OQ-10 (resolved — the derivation - OQ-02 (resolved by this ADR), OQ-10 (resolved: the derivation
contract lives on `mapping.md`'s exit rows; the PTY-path contract lives on `mapping.md`'s exit rows, and the PTY-path
signal-identity limitation is recorded upstream, alktty/portable-pty signal-identity limitation is recorded upstream on the
side) alktty/portable-pty side)
+72 -67
View File
@@ -3,7 +3,7 @@ status: reviewed
last_updated: 2026-10-29 last_updated: 2026-10-29
--- ---
# Mapping — server direction: SSH channels × family seams (the spine) # Mapping (server direction): SSH channels × family seams, the spine
## The door framing ## The door framing
@@ -12,7 +12,7 @@ family out. Vanilla `ssh`/`git` connect as they would to any sshd;
every accepted channel/request maps to **exactly one family protocol every accepted channel/request maps to **exactly one family protocol
seam** and **exactly one cargo feature** (ADR-003). Rows below are seam** and **exactly one cargo feature** (ADR-003). Rows below are
POC-3-validated against published sibling crates with real vanilla POC-3-validated against published sibling crates with real vanilla
tooling (OpenSSH 9.6, git 2.43, curl 8.5 — `poc-3-mapping-bakeoff.md`). tooling (OpenSSH 9.6, git 2.43, curl 8.5; see `poc-3-mapping-bakeoff.md`).
## The seams ## The seams
@@ -20,25 +20,25 @@ tooling (OpenSSH 9.6, git 2.43, curl 8.5 — `poc-3-mapping-bakeoff.md`).
|---|---|---| |---|---|---|
| alktty | `drive_session[_pre_negotiated]` | `(impl AsyncWrite, impl AsyncRead, backends, identity)` | | alktty | `drive_session[_pre_negotiated]` | `(impl AsyncWrite, impl AsyncRead, backends, identity)` |
| alkgit | `serve_duplex_resolved` | `(repo_id, identity, service, duplex, limits, backend traits)` | | alkgit | `serve_duplex_resolved` | `(repo_id, identity, service, duplex, limits, backend traits)` |
| alktunnels | — no serve entry (**absence note resolved**: route 1 never needed one, ADR-006) | consumer/open-op registrations | | alktunnels | no serve entry (**absence note resolved**: route 1 never needed one, ADR-006) | consumer/open-op registrations |
russh channels convert to duplex via `into_stream`/`split` — the russh channels convert to duplex via `into_stream`/`split`, the
bridge primitives every mapping uses (POC-1 §7). bridge primitives every mapping uses (POC-1 §7).
## Channel-type rows ## Channel-type rows
| SSH channel type | Vanilla use | Feature | Family seam | Contract notes | | SSH channel type | Vanilla use | Feature | Family seam | Contract notes |
|---|---|---|---|---| |---|---|---|---|---|
| `session` → `pty_request` + `shell_request` | `ssh host` interactive shell | `session` | alktty: `NegotiateRequest` constructed locally → `drive_session_pre_negotiated` | `pty_request` args → `TerminalParamsWire` (term/cols/rows/pixels; SSH modes → the reserved modes JSON); shell argv = the assembly-supplied serve-config shell (no crate default; unset → `shell_request` declines — OQ-08 resolved); the data plane is a **translation bridge**, not a byte pump — alktty is chunk-framed both directions (stdin/stdout/stderr/CTRL chunks): SSH data→stdin chunks; stdout chunks→SSH data; stderr chunks→`extended_data(1)`; exit chunk→`exit-status`/`exit-signal` + close (POC-3 §D.1) | | `session` → `pty_request` + `shell_request` | `ssh host` interactive shell | `session` | alktty: `NegotiateRequest` constructed locally → `drive_session_pre_negotiated` | `pty_request` args → `TerminalParamsWire` (term/cols/rows/pixels; SSH modes → the reserved modes JSON); shell argv = the assembly-supplied serve-config shell (no crate default; unset → `shell_request` declines, OQ-08 resolved); the data plane is a **translation bridge**, not a byte pump: alktty is chunk-framed both directions (stdin/stdout/stderr/CTRL chunks): SSH data→stdin chunks; stdout chunks→SSH data; stderr chunks→`extended_data(1)`; exit chunk→`exit-status`/`exit-signal` + close (POC-3 §D.1) |
| `session` → `exec_request` (non-git) | `ssh host "cmd"` | `session` | alktty pipe mode (`terminal: None`) — separate stderr | the wire's command string → the configured exec wrapper argv with the command appended as final element (`wrapper ++ [cmd]`, OQ-08 — never naively split); wrapper unset → clean decline; stderr rides `extended_data(1)` — only valid without PTY; PTY mode merges (kernel PTY, POC-3 §D.1 proven) | | `session` → `exec_request` (non-git) | `ssh host "cmd"` | `session` | alktty pipe mode (`terminal: None`), which separates stderr | the wire's command string → the configured exec wrapper argv with the command appended as final element (`wrapper ++ [cmd]`, OQ-08; never naively split); wrapper unset → clean decline; stderr rides `extended_data(1)` (only valid without PTY); PTY mode merges (kernel PTY, POC-3 §D.1 proven) |
| `session` → `exec_request` git | `git clone/push ssh://` | `git` | alkgit: parse command → `Service` + repo → `serve_duplex_resolved(repo_id, identity, service, duplex, …)` | **exec-only** (ADR-006 decision 2): the repo rides inside the exec command string (`git-upload-pack '/repo'`); vanilla git never speaks subsystem for git; process-mode dialect (server-speaks-first advertisement); exit-status proven both directions | | `session` → `exec_request` git | `git clone/push ssh://` | `git` | alkgit: parse command → `Service` + repo → `serve_duplex_resolved(repo_id, identity, service, duplex, …)` | **exec-only** (ADR-006 decision 2): the repo rides inside the exec command string (`git-upload-pack '/repo'`); vanilla git never speaks subsystem for git; process-mode dialect (server-speaks-first advertisement); exit-status proven both directions |
| `session` → `subsystem_request` | `sftp` clients; manual `ssh -s` | `sftp` (placeholder) / `git` rows | — | **`sftp` name**: declines until alkfs lands — the feature exists, the route doesn't (OQ-13 resolved: the tested decline placeholder is the shipped behavior until an alkfs-mapping ADR changes it). **git service names**: decline with reason — a subsystem request carries only a name, never a repo id (POC-3 §A.3); never half-serves | | `session` → `subsystem_request` | `sftp` clients; manual `ssh -s` | `sftp` (placeholder) / `git` rows | none | **`sftp` name**: declines until alkfs lands, since the feature exists but the route doesn't (OQ-13 resolved: the tested decline placeholder is the shipped behavior until an alkfs-mapping ADR changes it). **git service names**: decline with reason, since a subsystem request carries only a name, never a repo id (POC-3 §A.3); never half-serves |
| `direct-tcpip` | `ssh -L`; all `ssh -D` traffic | `tunnel` | alktunnels **route 1**: adapter-side bridge — `TunnelSession::open_direct` on a held `ChannelClient`, pump `take_halves()` vs the channel | target = `SubstrateAddr::HostPort`/`Path`; `Substrate` = Tcp/Unix; accept only **after** the fabric open succeeds — failure maps to an explicit `ConnectFailed` reject (POC-3 §C.2); vanilla `-D`'s dynamic targets dispatch here (POC-3 correction 2) | | `direct-tcpip` | `ssh -L`; all `ssh -D` traffic | `tunnel` | alktunnels **route 1**: adapter-side bridge, `TunnelSession::open_direct` on a held `ChannelClient`, pump `take_halves()` vs the channel | target = `SubstrateAddr::HostPort`/`Path`; `Substrate` = Tcp/Unix; accept only **after** the fabric open succeeds; failure maps to an explicit `ConnectFailed` reject (POC-3 §C.2); vanilla `-D`'s dynamic targets dispatch here (POC-3 correction 2) |
| `forwarded-tcpip` | `ssh -R` data channels | `tunnel` | the `register_tunnel_forwarded_openable`'s `ForwardedHandler` shape — hand the channel duplex to the handler | per fabric arrival the door opens a `forwarded-tcpip` channel **toward the vanilla client** (a vanilla client never initiates this type — sshd parity; an inbound open is the unvanilla class, declined `ConnectFailed`); echo args = the original `tcpip_forward` request's wire string verbatim + the actually-bound port (the sshd `c->path` + `get_local_port` parity — a `port = 0` mint echoes the allocated number); originator = `TunnelForwardedParams.peer`; correlation-miss → teardown-at-birth (never a half-open channel); the accept is the crate's per-session channel cap (the acceptor vocabulary) | | `forwarded-tcpip` | `ssh -R` data channels | `tunnel` | the `register_tunnel_forwarded_openable_with_establisher` gate shape (alktunnels 0.5.0, review-003 W2 adoption): the router's correlation lookup rides the establisher's `ForwardedGate`; the handler's `ForwardedHandler` shape hands the channel duplex to the pump | per fabric arrival the door opens a `forwarded-tcpip` channel **toward the vanilla client** (a vanilla client never initiates this type, per sshd parity; an inbound open is the unvanilla class, declined `ConnectFailed`); echo args = the original `tcpip_forward` request's wire string verbatim + the actually-bound port (the sshd `c->path` + `get_local_port` parity; a `port = 0` mint echoes the allocated number); originator = `TunnelForwardedParams.peer`; a stale/missing correlation token fails the open **typed at the call plane** (`channel:open_failed`, `unknown_resource`) before any channel is born (the gate's miss class); the narrow gate-pass→handler-run residual race and the cap-admission declines still teardown-at-birth (never a half-open channel); the accept is the crate's per-session channel cap (the acceptor vocabulary) |
| `direct-streamlocal` | unix-socket forward | `tunnel` | `SubstrateAddr::Path` treatment | compiles wherever `tunnel` compiles; serving is **runtime-Unix-gated** — on Unix serves, on other platforms a clean open-decline (OQ-11 resolved: universal compile + runtime decline, no per-platform feature fork) | | `direct-streamlocal` | unix-socket forward | `tunnel` | `SubstrateAddr::Path` treatment | compiles wherever `tunnel` compiles; serving is **runtime-Unix-gated**: on Unix it serves, on other platforms it is a clean open-decline (OQ-11 resolved: universal compile + runtime decline, no per-platform feature fork) |
| `x11` | X forwarding | — (never) | — | always declines; not feature-gated (ADR-003 §5) | | `x11` | X forwarding | none (never) | none | always declines; not feature-gated (ADR-003 §5) |
| `auth-agent` | agent forwarding | — (never) | — | always declines; the anti-pattern of the alkvault custody posture (ADR-003 §5) | | `auth-agent` | agent forwarding | none (never) | none | always declines; the anti-pattern of the alkvault custody posture (ADR-003 §5) |
| `session` → SOCKS5-in-channel | (no vanilla use) | — (retired, ADR-003 §1a) | — | vanilla `-D` is client-side SOCKS + `direct-tcpip`; the family local door is alksocks composed at the assembly — see `client.md` | | `session` → SOCKS5-in-channel | (no vanilla use) | none (retired, ADR-003 §1a) | none | vanilla `-D` is client-side SOCKS + `direct-tcpip`; the family local door is alksocks composed at the assembly (see `client.md`) |
## Session-channel dispatch (ADR-006, as amended) ## Session-channel dispatch (ADR-006, as amended)
@@ -47,62 +47,64 @@ parse path: `exec_request` parses first (a git-parseable command
routes to `git`; anything else to `session`'s pipe path), and routes to `git`; anything else to `session`'s pipe path), and
`pty_request`/`shell_request` route to `session`. Request-less session `pty_request`/`shell_request` route to `session`. Request-less session
channels decline as unfeatured. Parsing happens before dispatch and channels decline as unfeatured. Parsing happens before dispatch and
before any resource allocation — a leaked channel alloc is a before any resource allocation: a leaked channel alloc is a
capability leak, so **decline first, alloc never** (POC-3 stage A's capability leak, so **decline first, alloc never** (POC-3 stage A's
decline matrix is the contract: unknown repo → collapsed generic decline matrix is the contract: unknown repo → collapsed generic
denial *before any protocol byte* — no existence oracle; non-git denial *before any protocol byte*, no existence oracle; non-git
command → parse-then-fail). command → parse-then-fail).
## Global-request rows (the `-R` listen flow) ## Global-request rows (the `-R` listen flow)
`Handler::tcpip_forward(address, &mut port) -> bool` + `Handler::tcpip_forward(address, &mut port) -> bool` +
`Handler::streamlocal_forward` (+ both cancel forms) — gate on `Handler::streamlocal_forward` (+ both cancel forms), gated on
`tunnel` (unfeatured declines both the listen requests and the data `tunnel` (unfeatured declines both the listen requests and the data
channels). The served flow (`w3-tunnel-listen`, decidable against channels). The served flow (`w3-tunnel-listen`; the route-1 classes
alktunnels 0.4.1's actual surfaces with POC-3 §C.4 as the template): decided against alktunnels 0.4.1's recorded surfaces, the
establisher shape against 0.5.0 (the review-003 W2 adoption), with
POC-3 §C.4 as the template):
- **The listen dial** — family-ward through the consumer surface: - **The listen dial**, family-ward through the consumer surface:
`TunnelListener::open(channel_client, TunnelParams {resource: <the `TunnelListener::open(channel_client, TunnelParams {resource: <the
wire bind endpoint>, substrate: Tcp/Unix})`. The resource is built wire bind endpoint>, substrate: Tcp/Unix})`. The resource is built
from the wire bind endpoint: the address string rides verbatim (the from the wire bind endpoint: the address string rides verbatim (the
OpenSSH client normalizes OpenSSH client normalizes
the no-address case to `"localhost"` before it sends the no-address case to `"localhost"` before it sends
(`channel_rfwd_bind_host` parity), so the door passes through), with (`channel_rfwd_bind_host` parity), so the door passes through), with
the tcp port appended (`host:port`; `0` stays `0` — the OS-picking the tcp port appended (`host:port`; `0` stays `0`, because the OS-picking
is the fabric's bind step, whose bound report feeds the out-param); is the fabric's bind step, whose bound report feeds the out-param);
the unix path rides alone; whose the unix path rides alone; whose
bind policy governs (wildcard vs loopback — the sshd `GatewayPorts` bind policy governs (wildcard vs loopback, the sshd `GatewayPorts`
posture) is the fabric's establisher/bind closure, assembly policy posture) is the fabric's establisher/bind closure, assembly policy
at the family's provider. The dial is bounded (`LISTEN_DIAL_TIMEOUT` at the family's provider. The dial is bounded (`LISTEN_DIAL_TIMEOUT`
= 10 s, matching alkcall's own establisher deadline) and awaited = 10 s, matching alkcall's own establisher deadline) and awaited
**inline** in the hook by wire necessity (the global request's **inline** in the hook by wire necessity (the global request's
answer + the `&mut u32` out-param are the hook's own; the fabric answer + the `&mut u32` out-param are the hook's own; the fabric
leg's answer doesn't ride this session's routing, so the stall is leg's answer doesn't ride this session's routing, so the stall is
bounded, not deadlock-class) — recorded as the flow's one inline bounded, not deadlock-class); this is recorded as the flow's one inline
serving step. serving step.
- **The reply contract**: the minted `listen` token (alkcall ADR-049 - **The reply contract**: the minted `listen` token (alkcall ADR-049
amendment 3 establisher field) is REQUIRED — a producer that minted amendment 3 establisher field) is REQUIRED: a producer that minted
no token (or no `bound` report) gets a declined listen with its no token (or no `bound` report) gets a declined listen with its
fabric channel reaped immediately (a listen the door cannot fabric channel reaped immediately (a listen the door cannot
correlate arrivals onto never answers success). The bound port correlate arrivals onto never answers success). The bound port
writes the `&mut u32` out-param (`port = 0` → the actually-bound writes the `&mut u32` out-param (`port = 0` → the actually-bound
number — sshd's `allocated_listen_port` reply parity; the number; sshd's `allocated_listen_port` reply parity; the
`client.md` lifecycle primitive). `client.md` lifecycle primitive).
- **The door-side arrival surface** — the fabric's bind-first accept - **The door-side arrival surface**: the fabric's bind-first accept
dispatcher (`make_bindfirst_accept_handler`) dials the dispatcher (`make_bindfirst_accept_handler`) dials the
`tunnel/forwarded` op **toward the door** per arrival (the `tunnel/forwarded` op **toward the door** per arrival (the
`ForwardedOpener` closure); the door serves it through the alkcall `ForwardedOpener` closure); the door serves it through the alkcall
openable registered into the shared per-connection registry — the openable registered into the shared per-connection registry, the
serve-config's `TunnelListenSeam` (the `w3-seam-config` second serve-config's `TunnelListenSeam` (the `w3-seam-config` second
course-correction: the assembly's `Arc<ChannelClient>` alone course-correction: the assembly's `Arc<ChannelClient>` alone
carries no inbound-op serving loop; the seam fronts the carries no inbound-op serving loop; the seam fronts the
`ServingConfig.registry` the assembly wired at the leg's `ServingConfig.registry` the assembly wired at the leg's
`from_connection_with_serving`). The registration runs at `from_connection_with_serving`). The registration runs at
`SshAdapter::new` (a failure is a failed construction — `SshAdapter::new` (a failure is a failed construction,
`AdapterError::ListenRegistration`); alkcall's own gates carry the `AdapterError::ListenRegistration`); alkcall's own gates carry the
per-identity cap + the `tunnel:forwarded` scope per arrival, then per-identity cap + the `tunnel:forwarded` scope per arrival, then
the crate's arrival pump takes over. the crate's arrival pump takes over.
- **The arrival pump** — per the `forwarded-tcpip` row above; unix - **The arrival pump**: per the `forwarded-tcpip` row above; unix
substrate arrivals (a minted `streamlocal_forward` route) open substrate arrivals (a minted `streamlocal_forward` route) open
`forwarded-streamlocal@openssh.com` with the path echoed (russh's `forwarded-streamlocal@openssh.com` with the path echoed (russh's
server `Handle` carries the channel-open at this pin; the OQ-11 server `Handle` carries the channel-open at this pin; the OQ-11
@@ -111,13 +113,13 @@ alktunnels 0.4.1's actual surfaces with POC-3 §C.4 as the template):
- **The cancel forms + bounded bookkeeping**: the per-session - **The cancel forms + bounded bookkeeping**: the per-session
listener map (packet-loop-only, like the `channels` store) matches listener map (packet-loop-only, like the `channels` store) matches
cancels by (the wire endpoint as the request carried it, the bound cancels by (the wire endpoint as the request carried it, the bound
port — sshd's `c->path == host && listening_port == port` parity; port; the sshd `c->path == host && listening_port == port` parity;
the unix forms by path alone) 1:1 into the family listener's the unix forms by path alone) 1:1 into the family listener's
teardown (`TunnelListener::close`/`Drop` — the route leaves the teardown (`TunnelListener::close`/`Drop`; the route leaves the
shared router *before* the teardown races), `false` for an unknown shared router *before* the teardown races), `false` for an unknown
pair. The listen count is a bounded resource: a dedicated counter pair. The listen count is a bounded resource: a dedicated counter
against the same `Limits::max_concurrent_channels` ceiling (the against the same `Limits::max_concurrent_channels` ceiling (the
listener/channels mix stays unconstrained; both stay bounded — no listener/channels mix stays unconstrained; both stay bounded, so no
unbounded listener map). A session's teardown removes its tokens unbounded listener map). A session's teardown removes its tokens
from the shared router (a fabric arrival after the session is gone from the shared router (a fabric arrival after the session is gone
is the correlation-miss class). is the correlation-miss class).
@@ -127,21 +129,21 @@ alktunnels 0.4.1's actual surfaces with POC-3 §C.4 as the template):
| SSH request | Family mapping | | SSH request | Family mapping |
|---|---| |---|---|
| `pty_request` | `NegotiateRequest.tty = TerminalParamsWire{…}` (POC-3 §D.1: live `stty size` honored); ttymodes → the reserved modes JSON (OQ-44); a stored pty is consumed by the later `shell_request`/`exec_request` on the channel (`ssh -t host "cmd"`). A pre-serve `window_change_request` resizes the stored params (the final size wins) | | `pty_request` | `NegotiateRequest.tty = TerminalParamsWire{…}` (POC-3 §D.1: live `stty size` honored); ttymodes → the reserved modes JSON (OQ-44); a stored pty is consumed by the later `shell_request`/`exec_request` on the channel (`ssh -t host "cmd"`). A pre-serve `window_change_request` resizes the stored params (the final size wins) |
| `shell_request` | `NegotiateRequest.cmd` = the **assembly-supplied serve-config shell argv** verbatim (no crate default; unset → the serving-path decline — failure then close; never a fallback — OQ-08 resolved; never wire-supplied). **A shell request without a preceding pty serves pipe-mode (`tty = None`)** — vanilla sshd's posture; `ssh -T` is legitimate vanilla | | `shell_request` | `NegotiateRequest.cmd` = the **assembly-supplied serve-config shell argv** verbatim (no crate default; unset → the serving-path decline, failure then close; never a fallback, OQ-08 resolved; never wire-supplied). **A shell request without a preceding pty serves pipe-mode (`tty = None`)**, vanilla sshd's posture; `ssh -T` is legitimate vanilla |
| `exec_request` | parse → git dispatch (`git`) or alktty pipe `cmd` = exec wrapper argv with the command string appended (`wrapper ++ [cmd]`, OQ-08 — vanilla quoting semantics, never naively split; wrapper unset → the serving-path decline — failure then close; never a fallback); unknown → `channel_failure`, no resource alloc | | `exec_request` | parse → git dispatch (`git`) or alktty pipe `cmd` = exec wrapper argv with the command string appended (`wrapper ++ [cmd]`, OQ-08; vanilla quoting semantics, never naively split; wrapper unset → the serving-path decline, failure then close; never a fallback); unknown → `channel_failure`, no resource alloc |
| `subsystem_request` | decline (per the rows table; honest, with reason) | | `subsystem_request` | decline (per the rows table; honest, with reason) |
| `env_request` | **declined unconditionally** (OQ-09 resolved; no allowlist ships) — wire env is not family config | | `env_request` | **declined unconditionally** (OQ-09 resolved; no allowlist ships); wire env is not family config |
| `window_change_request` | resize control through the serving bridge (POC-3 §D.1: live resize proven); fire-and-forget — no SSH reply, so a full/closed control surface drops the event | | `window_change_request` | resize control through the serving bridge (POC-3 §D.1: live resize proven); fire-and-forget, no SSH reply, so a full/closed control surface drops the event |
| `signal` | signal control (alktty names verbatim — no door-side filter; PTY mode reaches the process group, pipe mode is pid-only — backend semantics, fidelity note, OQ-10); fire-and-forget, no decline shape | | `signal` | signal control (alktty names verbatim, no door-side filter; PTY mode reaches the process group, pipe mode is pid-only; backend semantics and a fidelity note, OQ-10); fire-and-forget, no decline shape |
| data / `extended_data(1)` | the translation bridge (rows table) | | data / `extended_data(1)` | the translation bridge (rows table) |
| `exit_status` | from the alktty exit chunk — sent **before** close, from the spawned task via `Session::handle()` (POC-3 §D: proven both legs). Derivation contract rules 1, 3–6 below | | `exit_status` | from the alktty exit chunk, sent **before** close, from the spawned task via `Session::handle()` (POC-3 §D: proven both legs). Derivation contract rules 1, 3–6 below |
| `exit_signal` | negative exit-chunk codes (the Unix pipe path's signal encoding) → `exit-signal(name)` — derivation contract rule 2; `−1` → `exit-status(255)`, never a signal claim (rule 3). PTY path: signal-terminated children arrive as a plain code 1 (portable-pty's exit surface) → `exit-status(1)` — the signal-identity loss is an upstream alktty/portable-pty constraint (rule 5) | | `exit_signal` | negative exit-chunk codes → `exit-signal(name)` (derivation contract rule 2); `−1` → `exit-status(255)`, never a signal claim (rule 3). Both the pipe **and** the PTY path report signal deaths as `−N` (alktty 0.8.2 reaps the PTY child directly; the former portable-pty flattening constraint is retired; rule 5) |
## Exit chunk → SSH exit reporting (the derivation contract) ## Exit chunk → SSH exit reporting (the derivation contract)
The alktty exit chunk (`{"type":"exit","code":N}`, exit-chunk-is-last — The alktty exit chunk (`{"type":"exit","code":N}`, exit-chunk-is-last;
alktty ADR-004, alknet ADR-055 lineage) is the sole fidelity input; the alktty ADR-004, alknet ADR-055 lineage) is the sole fidelity input; the
chunk is **not widened** (any widening needs its own alktty ADR — the chunk is **not widened** (any widening needs its own alktty ADR; the
convention is consumed as-is; OQ-10 resolved). Rules, normative for the convention is consumed as-is; OQ-10 resolved). Rules, normative for the
`exit_status`/`exit_signal` rows above: `exit_status`/`exit_signal` rows above:
@@ -149,7 +151,7 @@ convention is consumed as-is; OQ-10 resolved). Rules, normative for the
2. **Negative `−N`, N ≥ 2 (the Unix pipe path's signal encoding)** → 2. **Negative `−N`, N ≥ 2 (the Unix pipe path's signal encoding)** →
`exit-signal(name)` via a Unix signal-number→name table (server-side `exit-signal(name)` via a Unix signal-number→name table (server-side
work; signal semantics belong to the serving platform, where the work; signal semantics belong to the serving platform, where the
backend reaps). `core_dumped` is **never asserted** — alktty discards backend reaps). `core_dumped` is **never asserted**: alktty discards
`ExitStatus::core_dumped()`, so nothing better is knowable from the `ExitStatus::core_dumped()`, so nothing better is knowable from the
chunk, and SSH's `exit-signal` bool stays false. (POC-3 §D.2 observed chunk, and SSH's `exit-signal` bool stays false. (POC-3 §D.2 observed
the full derivation live: signal TERM → `−15` → `exit-signal("TERM")`.) the full derivation live: signal TERM → `−15` → `exit-signal("TERM")`.)
@@ -157,7 +159,7 @@ convention is consumed as-is; OQ-10 resolved). Rules, normative for the
alktty's could-not-determine sentinel collides with the SIGHUP alktty's could-not-determine sentinel collides with the SIGHUP
encoding on the Unix pipe path, and this rule accepts the fidelity encoding on the Unix pipe path, and this rule accepts the fidelity
cost symmetrically: neither a fabricated signal for a wait failure cost symmetrically: neither a fabricated signal for a wait failure
(a signal that never happened) nor a real one — a *genuine* SIGHUP (a signal that never happened) nor a real one: a *genuine* SIGHUP
death on the pipe path also reports 255, not `exit-signal("HUP")`. death on the pipe path also reports 255, not `exit-signal("HUP")`.
If the wait-failure vs SIGHUP distinction ever becomes load-bearing, If the wait-failure vs SIGHUP distinction ever becomes load-bearing,
alktty's additive `exit_error` control type is the extension seam alktty's additive `exit_error` control type is the extension seam
@@ -165,28 +167,31 @@ convention is consumed as-is; OQ-10 resolved). Rules, normative for the
carries, with no contract change here. carries, with no contract change here.
4. **Non-Unix**: no signal termination exists; `−1` is the only 4. **Non-Unix**: no signal termination exists; `−1` is the only
negative that can appear → rule 3. negative that can appear → rule 3.
5. **The PTY path (fidelity limit, upstream)**: portable-pty 0.9 reaps 5. **The PTY path (constraint retired, alktty 0.8.2)**: portable-pty
a signal-terminated Unix child as a plain code 1 (the signal number 0.9's `ExitStatus` flattens a signal-terminated Unix child to a
is unrecoverable through its trait surface — verified against plain code 1, so PTY-row signal deaths once arrived as
portable-pty 0.9.0's source) → PTY-row signal-terminated sessions `exit-status(1)`. alktty 0.8.2 retires the constraint (its Unix
arrive as `exit-status(1)`. Faithful `exit-signal` there is an PTY waiter skips portable-pty's conversion and reaps the direct
alktty/portable-pty ask with tty/pty-grounded justification; alkssh's child via `waitpid`, decoding signal deaths as `−N`); the PTY path
contract adapts automatically if the convention ever holds on that now reports signal termination exactly like the pipe path, and the
path. Pipe-mode pid-only vs PTY process-group *scope* (the `signal` derivation here needed **no contract change**: the negative-code
convention is consumed verbatim (OQ-10's upstream constraint
closed). Pipe-mode pid-only vs PTY process-group *scope* (the
`signal`
row note) is backend semantics, not a mapping detail. row note) is backend semantics, not a mapping detail.
6. **Unmapped negatives → rule 3.** The convention's producers are 6. **Unmapped negatives → rule 3.** The convention's producers are
`−1` and `−signum` only; a negative value with no entry in the `−1` and `−signum` only; a negative value with no entry in the
signal-name table (defensive case — the mapping never invents a signal-name table (defensive case: the mapping never invents a
name) reports `exit-status(255)`. name) reports `exit-status(255)`.
## The structural constraint: spawn-and-return ## The structural constraint: spawn-and-return
russh (this pin: ADR-002) runs server handler callbacks **inline russh (this pin: ADR-002) runs server handler callbacks **inline
inside the session's packet loop** — a blocking handler suspends all inside the session's packet loop**, which means a blocking handler
packet routing for the connection (channel data is routed by that suspends all packet routing for the connection (channel data is routed
same loop; an inline serve deadlocks against the peer's immediate by that same loop; an inline serve deadlocks against the peer's
follow-up bytes — POC-3 proved by construction with receive-pack). immediate follow-up bytes, as POC-3 proved by construction with
Therefore: **every serving path is spawned; handlers return fast**; receive-pack). Therefore: **every serving path is spawned; handlers return fast**;
exit/status reporting from spawned tasks rides the cloned exit/status reporting from spawned tasks rides the cloned
`Session::handle()`. This is a load-bearing correctness constraint of `Session::handle()`. This is a load-bearing correctness constraint of
the whole spine, not an implementation choice. the whole spine, not an implementation choice.
@@ -196,7 +201,7 @@ the whole spine, not an implementation choice.
- russh knobs (`window_size`, `maximum_packet_size`, - russh knobs (`window_size`, `maximum_packet_size`,
`channel_buffer_size`, `Limits`, `inactivity_timeout`, `channel_buffer_size`, `Limits`, `inactivity_timeout`,
`keepalive_*`) + in-crate max-concurrent-channels cap + per-length `keepalive_*`) + in-crate max-concurrent-channels cap + per-length
exec-parse limits — per the bounded-resources policy in exec-parse limits, per the bounded-resources policy in
`overview.md`. `overview.md`.
- alkcall bridge leg (when the family half is remote): channels-layer - alkcall bridge leg (when the family half is remote): channels-layer
reassembly cap 1 MiB/channel; ADR-034 grants `alk/ssh` stream_types reassembly cap 1 MiB/channel; ADR-034 grants `alk/ssh` stream_types
@@ -210,26 +215,26 @@ the whole spine, not an implementation choice.
| Build | Serves | Declines | | Build | Serves | Declines |
|---|---|---| |---|---|---|
| none (default) | — | everything | | none (default) | nothing | everything |
| `git` | git-parseable exec | pty/shell, plain exec, all other channel types, all `session` requests | | `git` | git-parseable exec | pty/shell, plain exec, all other channel types, all `session` requests |
| `session` | pty/shell/pipe-exec (requires the configured shell/exec wrapper — OQ-08; an unset field declines its row though the feature is on) | git-parseable exec (the `git` parse is its gate), other channel types | | `session` | pty/shell/pipe-exec (requires the configured shell/exec wrapper, OQ-08; an unset field declines its row though the feature is on) | git-parseable exec (the `git` parse is its gate), other channel types |
| `tunnel` | direct/forwarded-tcpip, streamlocal (unix), `-R` listens + arrivals (the listen forms additionally require the configured `TunnelListenSeam` — an egress-only door declines just the `-R` forms) | session channels (auto-reject), all others | | `tunnel` | direct/forwarded-tcpip, streamlocal (unix), `-R` listens + arrivals (the listen forms additionally require the configured `TunnelListenSeam`; an egress-only door declines just the `-R` forms) | session channels (auto-reject), all others |
| any build + `sftp` slot | unchanged from the underlying build | `sftp` subsystems decline-with-reason until alkfs | | any build + `sftp` slot | unchanged from the underlying build | `sftp` subsystems decline-with-reason until alkfs |
Every row is tested in *every* build it can appear in — the decline Every row is tested in *every* build it can appear in: the decline
paths are contract territory (AGENTS.md conventions 5–6), including paths are contract territory (AGENTS.md conventions 5–6), including
under `--all-features`. under `--all-features`.
## Open Questions ## Open Questions
None open or deferred — see [open-questions.md](open-questions.md) None open or deferred; see [open-questions.md](open-questions.md)
(all of OQ-01…13 resolved). Doc-local contract anchors: OQ-02 (session (all of OQ-01…13 resolved). Doc-local contract anchors: OQ-02 (session
dispatch, ADR-006 as amended — §above), OQ-08 (the shell argv / exec dispatch, ADR-006 as amended, §above), OQ-08 (the shell argv / exec
wrapper serve config — `shell_request`/`exec_request` rows and the wrapper serve config, the `shell_request`/`exec_request` rows and the
`session→pty+shell` channel row), OQ-09 (the `env_request` row), `session→pty+shell` channel row), OQ-09 (the `env_request` row),
OQ-10 (§the derivation contract — formalized, upstream constraint OQ-10 (§the derivation contract, now formalized; the former upstream PTY
noted there), OQ-11 (`direct-streamlocal` row + the listen-flow gate), constraint is retired as of alktty 0.8.2, rule 5), OQ-11 (`direct-streamlocal` row + the listen-flow gate),
OQ-13 (the `sftp` rows — the decline placeholder is the shipped OQ-13 (the `sftp` rows; the decline placeholder is the shipped
behavior). behavior).
## References ## References
+54 -52
View File
@@ -15,114 +15,114 @@ resolutions only; the genuinely-upstream items it surfaced (the PTY
exit limit, the alkvault zeroization gap, the alkfs dependency) are exit limit, the alkvault zeroization gap, the alkfs dependency) are
noted at the end and carry their work where their owners are. noted at the end and carry their work where their owners are.
## Theme A — wire & family topology ## Theme A: wire & family topology
### OQ-01: ~~Does an `alk/ssh` relay/browser-leg consumer emerge?~~ — resolved ### OQ-01: ~~Does an `alk/ssh` relay/browser-leg consumer emerge?~~ (resolved)
- **Origin**: [mapping-table.md](../research/mapping-table.md) §topology, `vision.md` scope Q1 - **Origin**: [mapping-table.md](../research/mapping-table.md) §topology, `vision.md` scope Q1
- **Status**: resolved — ADR-001 is the entire decision surface; what remained was a forecast about the world, not an alkssh decision. - **Status**: resolved; ADR-001 is the entire decision surface; what remained was a forecast about the world, not an alkssh decision.
- **Resolution**: no relay/browser-leg consumer is in scope and no crate behavior is gated on one. ADR-001 decided everything decidable now: the `alk/ssh` pin's raw-stream contract (§1), payload-blind relay as part of the contract (§2), the phase-1 edge-termination-only scope (§3), and future-leg compatibility by construction (§4). Whether a consumer ever materializes is an assembly/family use-case question — a future leg arrives via its own ADR referencing ADR-001's already-fixed contract and would exercise `client.md`'s accept surface in a new role. Nothing in the crate waits on this. (Door-type note retained for the record: the pin is one-way; consumer *existence* was never an architecture decision.) - **Resolution**: no relay/browser-leg consumer is in scope and no crate behavior is gated on one. ADR-001 decided everything decidable now: the `alk/ssh` pin's raw-stream contract (§1), payload-blind relay as part of the contract (§2), the phase-1 edge-termination-only scope (§3), and future-leg compatibility by construction (§4). Whether a consumer ever materializes is an assembly/family use-case question: a future leg arrives via its own ADR referencing ADR-001's already-fixed contract and would exercise `client.md`'s accept surface in a new role. Nothing in the crate waits on this. (Door-type note retained for the record: the pin is one-way; consumer *existence* was never an architecture decision.)
- **Cross-references**: ADR-001, OQ-13 - **Cross-references**: ADR-001, OQ-13
## Theme B — session-channel dispatch & mapping details ## Theme B: session-channel dispatch & mapping details
### OQ-02: ~~What is the session-channel dispatch shape (SOCKS-detect vs parse, and who bounds the window)?~~ — resolved ### OQ-02: ~~What is the session-channel dispatch shape (SOCKS-detect vs parse, and who bounds the window)?~~ (resolved)
- **Origin**: [poc-3-mapping-bakeoff.md](../research/poc-3-mapping-bakeoff.md) §Stage B.3, `mapping-table.md` §notes - **Origin**: [poc-3-mapping-bakeoff.md](../research/poc-3-mapping-bakeoff.md) §Stage B.3, `mapping-table.md` §notes
- **Status**: resolved. Decided by ADR-006 point 3, then simplified by the ADR-003 §1a amendment: the `dynamic-forward` feature retired (no consumer is ever the family-native SOCKS-in-channel client; vanilla `-D` is client-side SOCKS + plain `direct-tcpip`; the family `-D` door is alksocks composed locally at the assembly), so the detect lost its owner. **Session dispatch is parse-only**; request-less session channels decline. POC-3 stage B survives as the feasibility record for re-adding a remote SOCKS door via explicit ADR. - **Status**: resolved. Decided by ADR-006 point 3, then simplified by the ADR-003 §1a amendment: the `dynamic-forward` feature retired (no consumer is ever the family-native SOCKS-in-channel client; vanilla `-D` is client-side SOCKS + plain `direct-tcpip`; the family `-D` door is alksocks composed locally at the assembly), so the detect lost its owner. **Session dispatch is parse-only**; request-less session channels decline. POC-3 stage B survives as the feasibility record for re-adding a remote SOCKS door via explicit ADR.
- **Cross-references**: ADR-006, ADR-003 §1a - **Cross-references**: ADR-006, ADR-003 §1a
### OQ-03: ~~Username disposition~~ — resolved ### OQ-03: ~~Username disposition~~ (resolved)
- **Origin**: [poc-1-auth-bridge.md](../research/poc-1-auth-bridge.md) §2, `auth-bridge.md` §The mapping - **Origin**: [poc-1-auth-bridge.md](../research/poc-1-auth-bridge.md) §2, `auth-bridge.md` §The mapping
- **Status**: resolved - **Status**: resolved
- **Resolution**: usernames are never the identity model and carry no routing semantics — the fingerprint is the sole resolution path (POC-1: `ssh anything@host` works with the same identity). Whether a username string appears in logs is deployment policy, outside the crate. Rides as the recorded contract in the `auth.md` spec / ADR-004. - **Resolution**: usernames are never the identity model and carry no routing semantics; the fingerprint is the sole resolution path (POC-1: `ssh anything@host` works with the same identity). Whether a username string appears in logs is deployment policy, outside the crate. Rides as the recorded contract in the `auth.md` spec / ADR-004.
### OQ-04: ~~OpenSSH certificate auth — is there ever a CA-trust use case?~~ — resolved ### OQ-04: ~~OpenSSH certificate auth, is there ever a CA-trust use case?~~ (resolved)
- **Origin**: `mapping-table.md` (auth rows), [poc-1-auth-bridge.md](../research/poc-1-auth-bridge.md) OQ seed 4 - **Origin**: `mapping-table.md` (auth rows), [poc-1-auth-bridge.md](../research/poc-1-auth-bridge.md) OQ seed 4
- **Status**: resolved — the decline is the settled disposition; the "is there ever" remainder was a forecast. - **Status**: resolved. The decline is the settled disposition; the "is there ever" remainder was a forecast.
- **Resolution**: `auth_openssh_certificate` is declined unconditionally (ADR-004 decision 3: explicit reject implementation, contract-tested, POC-1-proven). There is no CA-trust story in the family identity model — exactly one SSH-shaped resolution path exists (verified key fingerprint → `IdentityProvider::resolve_from_fingerprint` → `Identity`). A certificate trust model would first require the family identity model to gain one upstream (alkcall identity territory) and would then enter alkssh as a new ADR — an auth-posture change is wire-visible. Nothing in the crate waits on this; no CA use case exists. - **Resolution**: `auth_openssh_certificate` is declined unconditionally (ADR-004 decision 3: explicit reject implementation, contract-tested, POC-1-proven). There is no CA-trust story in the family identity model: exactly one SSH-shaped resolution path exists (verified key fingerprint → `IdentityProvider::resolve_from_fingerprint` → `Identity`). A certificate trust model would first require the family identity model to gain one upstream (alkcall identity territory) and would then enter alkssh as a new ADR, since an auth-posture change is wire-visible. Nothing in the crate waits on this; no CA use case exists.
- **Cross-references**: ADR-004, [auth.md](auth.md) §out-of-scope - **Cross-references**: ADR-004, [auth.md](auth.md) §out-of-scope
## Theme C — auth, identity & key custody ## Theme C: auth, identity & key custody
### OQ-05: ~~What is the client-side host-key pin store?~~ — resolved (policy-seam shape) ### OQ-05: ~~What is the client-side host-key pin store?~~ (resolved, policy-seam shape)
- **Origin**: [auth-bridge.md](../research/auth-bridge.md) §Host keys, `reverse-runner.md` §5 - **Origin**: [auth-bridge.md](../research/auth-bridge.md) §Host keys, `reverse-runner.md` §5
- **Status**: resolved — the *seam* is decided; where learned pins persist is assembly detail (the crate takes a policy, not a pin store). - **Status**: resolved. The *seam* is decided; where learned pins persist is assembly detail (the crate takes a policy, not a pin store).
- **Resolution**: two basic client cases, one seam: - **Resolution**: two basic client cases, one seam:
1. **Known beforehand** — trust anchors exist. Family-built runners mint their host key at assembly time, so the expected fingerprint is known before first contact: assembly-supplied pins, **hard decline on mismatch** (no soft failure). 1. **Known beforehand**: trust anchors exist. Family-built runners mint their host key at assembly time, so the expected fingerprint is known before first contact: assembly-supplied pins, **hard decline on mismatch** (no soft failure).
2. **Unknown beforehand** — vanilla `sshd` on container fleets the assembly didn't provision; no anchor exists until first contact. The crate surfaces the presented host key (host, port, fingerprint) to an **assembly-provided `HostKeyPolicy`** that decides: prompt-and-record, provider-metadata match, learn-and-record, or accept-and-continue (the ephemeral-fleet shape — see below). The OpenSSH "add to known_hosts?" moment becomes an assembly policy rather than crate behavior. 2. **Unknown beforehand**: vanilla `sshd` on container fleets the assembly didn't provision; no anchor exists until first contact. The crate surfaces the presented host key (host, port, fingerprint) to an **assembly-provided `HostKeyPolicy`** that decides: prompt-and-record, provider-metadata match, learn-and-record, or accept-and-continue (the ephemeral-fleet shape, see below). The OpenSSH "add to known_hosts?" moment becomes an assembly policy rather than crate behavior.
Crate rules that make this family-shaped: the crate's default policy is **fail-closed** (reject unknown, hard-decline mismatch — russh's own reject-all default, kept); the crate never decides trust itself and never hard-codes a trust model — accept-all is not a built-in crate option, but a permissive case-2 policy is the assembly's to choose where its fleet model calls for it (alkminer's accept-all for rental fleets is that shape, made deliberately: fresh container per rent → fresh host key, so no pin can exist beforehand and the security anchor is hub-held per-instance dial keys plus what rides the tunnels, not host pins). The contract lands in the `auth.md` spec (host-key policy section) and `client.md`'s dial surface. Crate rules that make this family-shaped: the crate's default policy is **fail-closed** (reject unknown, hard-decline mismatch; russh's own reject-all default, kept); the crate never decides trust itself and never hard-codes a trust model, so accept-all is not a built-in crate option, but a permissive case-2 policy is the assembly's to choose where its fleet model calls for it (alkminer's accept-all for rental fleets is that shape, made deliberately: fresh container per rent → fresh host key, so no pin can exist beforehand and the security anchor is hub-held per-instance dial keys plus what rides the tunnels, not host pins). The contract lands in the `auth.md` spec (host-key policy section) and `client.md`'s dial surface.
- **Cross-references**: OQ-07, `reverse-runner.md`, [poc-2-vault-keys.md](../research/poc-2-vault-keys.md) §5 (host keys ride the vault model) - **Cross-references**: OQ-07, `reverse-runner.md`, [poc-2-vault-keys.md](../research/poc-2-vault-keys.md) §5 (host keys ride the vault model)
### OQ-06: ~~One vault, many roles?~~ — resolved ### OQ-06: ~~One vault, many roles?~~ (resolved)
- **Origin**: [poc-2-vault-keys.md](../research/poc-2-vault-keys.md) OQ seed 3 - **Origin**: [poc-2-vault-keys.md](../research/poc-2-vault-keys.md) OQ seed 3
- **Status**: resolved - **Status**: resolved
- **Resolution**: not an alkssh constraint. The crate takes vault handles the assembly injects; whether the assembly runs separate vault instances per role (client keys vs host keys) is assembly policy. ADR-005 records the boundary: vault handle in, key material out, version semantics owned by the vault. - **Resolution**: not an alkssh constraint. The crate takes vault handles the assembly injects; whether the assembly runs separate vault instances per role (client keys vs host keys) is assembly policy. ADR-005 records the boundary: vault handle in, key material out, version semantics owned by the vault.
### OQ-07: ~~Is the alkvault `decrypt` plain-`String` zeroization gap closed at the vault or at the adapter?~~ — resolved (at this level) ### OQ-07: ~~Is the alkvault `decrypt` plain-`String` zeroization gap closed at the vault or at the adapter?~~ (resolved, at this level)
- **Origin**: [poc-2-vault-keys.md](../research/poc-2-vault-keys.md) §2 + OQ seed 1 - **Origin**: [poc-2-vault-keys.md](../research/poc-2-vault-keys.md) §2 + OQ seed 1
- **Status**: resolved — the alkssh-level answer is the adapter-side wrap; the vault-side improvement belongs upstream, not here. - **Status**: resolved. The alkssh-level answer was the adapter-side wrap; the vault-side improvement belongs upstream, not here. **Superseded upstream 2026-11-04 (alkvault 0.2.0)**: the ask shipped, `decrypt` returns `Zeroizing<String>`, zeroizing the buffer on every path including the error one, and the `rotate` path never holds a bare plaintext either.
- **Resolution**: **ADR-005 decision 3 is the contract on this path**: the adapter owns a `Zeroizing<String>` wrap at the seam (POC-2-proven; bounds the plaintext from adapter-takeover onward; ssh-key zeroizes the decoded key bytes on drop). The remaining unfenced pre-wrap window is **alkvault's API shape to close** — the ask is filed in alkvault's own tracker (downstream/family-space concern; alkforge and every secrets-bearing crate benefits, not just alkssh). alkssh does not carry it, does not gate on it, and ADR-005 records only the adapter obligation. When alkvault ships a `Zeroizing`-out API, ADR-005's reference here updates as a one-line doc touch, nothing else. - **Resolution**: **ADR-005 decision 3 was the contract on this path**: the adapter owned a `Zeroizing<String>` wrap at the seam (POC-2-proven; bounds the plaintext from adapter-takeover onward; ssh-key zeroizes the decoded key bytes on drop). The remaining unfenced pre-wrap window was **alkvault's API shape to close**; the ask was filed in alkvault's own tracker (downstream/family-space concern; alkforge and every secrets-bearing crate benefits, not just alkssh). alkssh did not carry it, did not gate on it, and ADR-005 recorded only the adapter obligation. With alkvault 0.2.0 the window is closed upstream: the adapter-side wrap is dropped (a double wrap would be dead posture), `decrypt_and_decode` consumes the vault's zeroized return directly, and ADR-005's decision-3 pointer is amended (the predicted one-line doc touch, exactly).
- **Cross-references**: ADR-005 §3, OQ-05 - **Cross-references**: ADR-005 §3, OQ-05
## Theme D — session-request mapping details ## Theme D: session-request mapping details
### OQ-08: ~~Shell command convention for `shell_request`~~ — resolved ### OQ-08: ~~Shell command convention for `shell_request`~~ (resolved)
- **Origin**: `mapping-table.md` (shell row), [poc-3-mapping-bakeoff.md](../research/poc-3-mapping-bakeoff.md) OQ seed 3 - **Origin**: `mapping-table.md` (shell row), [poc-3-mapping-bakeoff.md](../research/poc-3-mapping-bakeoff.md) OQ seed 3
- **Status**: resolved — assembly-supplied serve-config field; no crate default. - **Status**: resolved; assembly-supplied serve-config field, no crate default.
- **Resolution**: the shell convention is **assembly-supplied serve configuration** — the crate bakes no default, and each unset field means its request **declines with a clean error** (never a silent fallback). Grounding for the shape: alktty's contract requires a non-empty `cmd` vector (there is no pass-through-absence seam at `drive_session`), the wire supplies nothing (`shell_request` carries no command), and the crate is not an sshd — it has no account/passwd model from which "the user's shell" could be derived, so a crate-level default (`bash`, `sh`) would be deployment policy smuggled into the crate (the composability boundary, `overview.md`). Two fields, each governing its own row: - **Resolution**: the shell convention is **assembly-supplied serve configuration**: the crate bakes no default, and each unset field means its request **declines with a clean error** (never a silent fallback). Grounding for the shape: alktty's contract requires a non-empty `cmd` vector (there is no pass-through-absence seam at `drive_session`), the wire supplies nothing (`shell_request` carries no command), and the crate is not an sshd and has no account/passwd model from which "the user's shell" could be derived, so a crate-level default (`bash`, `sh`) would be deployment policy smuggled into the crate (the composability boundary, `overview.md`). Two fields, each governing its own row:
1. **shell argv** — what `shell_request` runs verbatim as `NegotiateRequest.cmd`. 1. **shell argv**: what `shell_request` runs verbatim as `NegotiateRequest.cmd`.
2. **exec wrapper argv** (e.g. `["/bin/sh", "-c"]`) — what a non-git `exec_request` translates to: the wire's single command string is **appended as the wrapper argv's final element** (`wrapper ++ [cmd]`), preserving vanilla `ssh host "cmd"` quoting/expansion semantics — command strings are never naively split (that would break quoting). The wrapper form is platform-neutral: the assembly configures whatever its fleet's shell is. 2. **exec wrapper argv** (e.g. `["/bin/sh", "-c"]`): what a non-git `exec_request` translates to. The wire's single command string is **appended as the wrapper argv's final element** (`wrapper ++ [cmd]`), preserving vanilla `ssh host "cmd"` quoting/expansion semantics; command strings are never naively split (that would break quoting). The wrapper form is platform-neutral: the assembly configures whatever its fleet's shell is.
Unset field → its row declines; a `session` build with no configured shell serves neither shell nor exec (fail-closed — no half-capability where the feature is on). The git row is unaffected (parse to `alkgit` first — it never needs a shell). `cwd`/`env` in `NegotiateRequest` are assembly-supplied serve-config values, never wire-derived (`env_request` stays declined — OQ-09). Recorded on `mapping.md`'s `shell_request`/`exec_request` rows. Unset field → its row declines; a `session` build with no configured shell serves neither shell nor exec (fail-closed, no half-capability where the feature is on). The git row is unaffected (parse to `alkgit` first, since it never needs a shell). `cwd`/`env` in `NegotiateRequest` are assembly-supplied serve-config values, never wire-derived (`env_request` stays declined, OQ-09). Recorded on `mapping.md`'s `shell_request`/`exec_request` rows.
- **Cross-references**: OQ-09, [mapping.md](mapping.md) - **Cross-references**: OQ-09, [mapping.md](mapping.md)
### OQ-09: ~~`env_request` policy — is an allowlist ever wanted?~~ — resolved ### OQ-09: ~~`env_request` policy, is an allowlist ever wanted?~~ (resolved)
- **Origin**: `mapping-table.md` (env_request row) - **Origin**: `mapping-table.md` (env_request row)
- **Status**: resolved — decline-by-default is the fixed contract; no allowlist ships. - **Status**: resolved. Decline-by-default is the fixed contract; no allowlist ships.
- **Resolution**: `env_request` is declined **unconditionally**. Wire-supplied environment is not family config: session env comes from assembly serve configuration (`NegotiateRequest.env`), never from the wire. The only open shape was whether an allowlist ships — it does not; a future allowlist would be a new ADR (a serving capability is wire-visible) on a concrete consumer need, of which none exists. - **Resolution**: `env_request` is declined **unconditionally**. Wire-supplied environment is not family config: session env comes from assembly serve configuration (`NegotiateRequest.env`), never from the wire. The only open shape was whether an allowlist ships; it does not, and a future allowlist would be a new ADR (a serving capability is wire-visible) on a concrete consumer need, of which none exists.
- **Cross-references**: OQ-08, [mapping.md](mapping.md) (`env_request` row) - **Cross-references**: OQ-08, [mapping.md](mapping.md) (`env_request` row)
### OQ-10: ~~SSH `exit-signal` fidelity over the alktty exit-code convention~~ — resolved (closed as contract + upstream constraint) ### OQ-10: ~~SSH `exit-signal` fidelity over the alktty exit-code convention~~ (resolved: closed as contract + upstream constraint)
- **Origin**: `mapping-table.md` (exit_signal row), [poc-3-mapping-bakeoff.md](../research/poc-3-mapping-bakeoff.md) §D.2. **Premise corrected 2026-10-27** (with the alktty side): the POC seed's "the exit chunk carries no signal identity — alktty might widen the chunk" framing was wrong; the negative code *is* the signal encoding. Integrated into the seed rather than argued after the fact. - **Origin**: `mapping-table.md` (exit_signal row), [poc-3-mapping-bakeoff.md](../research/poc-3-mapping-bakeoff.md) §D.2. **Premise corrected 2026-10-27** (with the alktty side): the POC seed's "the exit chunk carries no signal identity (alktty might widen the chunk)" framing was wrong; the negative code *is* the signal encoding. Integrated into the seed rather than argued after the fact.
- **Status**: resolved — as asked, this was never a decision to make: the convention alkssh consumes does not change (alktty's exit-chunk convention, alktty ADR-004 / alknet ADR-055 lineage — no widening proposed or justified; "easier for alkssh" is not a tty/pty-grounded justification, and any widening ripples `ControlMessage::Exit`'s parsed shape, the backend API, and that ADR lineage, so it needs its own alktty ADR). What the entry surfaced was (a) a *derivation contract* on alkssh's side and (b) an upstream *fidelity limitation* — both now recorded where they constrain. - **Status**: resolved. As asked, this was never a decision to make: the convention alkssh consumes does not change (alktty's exit-chunk convention, alktty ADR-004 / alknet ADR-055 lineage; no widening proposed or justified). "Easier for alkssh" is not a tty/pty-grounded justification, and any widening ripples `ControlMessage::Exit`'s parsed shape, the backend API, and that ADR lineage, so it needs its own alktty ADR. What the entry surfaced was (a) a *derivation contract* on alkssh's side and (b) an upstream *fidelity limitation*, both now recorded where they constrain.
- **Resolution**: - **Resolution**:
- **The derivation contract** is formalized as `mapping.md` §"Exit chunk → SSH exit reporting" (the contract behind the `exit_status`/`exit_signal` rows): `code ≥ 0` → `exit-status(N)`; negative `−N` on the Unix pipe path → `exit-signal(name)` via a server-side Unix signal-number→name table; `−1` → `exit-status(255)` and never a signal claim (the sentinel collides with SIGHUP; fabricating `exit-signal("HUP")` for a wait failure invents a signal that never happened); non-Unix negatives → the `−1` rule; `core_dumped` never asserted. POC-3 §D.2 observed the pipe-path derivation live (`−15` → `exit-signal("TERM")`). - **The derivation contract** is formalized as `mapping.md` §"Exit chunk → SSH exit reporting" (the contract behind the `exit_status`/`exit_signal` rows): `code ≥ 0` → `exit-status(N)`; negative `−N` on the Unix pipe path → `exit-signal(name)` via a server-side Unix signal-number→name table; `−1` → `exit-status(255)` and never a signal claim (the sentinel collides with SIGHUP; fabricating `exit-signal("HUP")` for a wait failure invents a signal that never happened); non-Unix negatives → the `−1` rule; `core_dumped` never asserted. POC-3 §D.2 observed the pipe-path derivation live (`−15` → `exit-signal("TERM")`).
- **The upstream constraint**: the PTY path loses the signal number (portable-pty 0.9 reaps a signal-terminated Unix child as a plain code 1 through its trait surface — verified against its source) → PTY-row signal-terminated sessions arrive as `exit-status(1)`. Faithful `exit-signal` on the PTY row is an **alktty/portable-pty ask with tty/pty-grounded justification**; alkssh's mapping adapts automatically with no contract change if the convention ever holds on that path. Recorded as rule 5 of `mapping.md`'s derivation contract (not tracked here — it constrains alktty, not alkssh). - **The upstream constraint** (superseded 2026-11-04, alktty 0.8.2): the PTY path used to lose the signal number (portable-pty 0.9 reaps a signal-terminated Unix child as a plain code 1 through its trait surface; verified against its source). alktty 0.8.2 retired that constraint consumer-input-driven (alktty review 004, this entry's bookkeeping): its Unix PTY waiter now reaps the direct child via `waitpid` and decodes signal deaths as `−N`, so the convention holds on both paths and the PTY-row signal-terminated session reports `exit-signal(name)` with **no alkssh contract change** (the negative code is consumed verbatim; `mapping.md` rule 5 amended). The `-1` sentinel collision (SIGHUP vs wait-failure) remains on the pipe path and gains the cancel path; the rule-3 reading stands unchanged.
- **Cross-references**: OQ-08, [mapping.md](mapping.md) (derivation contract + `exit_signal`/`exit_status`/`signal` rows), ADR-006, alktty ADR-004 / alknet ADR-055 - **Cross-references**: OQ-08, [mapping.md](mapping.md) (derivation contract + `exit_signal`/`exit_status`/`signal` rows), ADR-006, alktty ADR-004 / alknet ADR-055
### OQ-11: ~~`direct-streamlocal` platform surface — compile-time or runtime decline shape?~~ — resolved ### OQ-11: ~~`direct-streamlocal` platform surface, compile-time or runtime decline shape?~~ (resolved)
- **Origin**: `mapping-table.md` (streamlocal row) - **Origin**: `mapping-table.md` (streamlocal row)
- **Status**: resolved — universal compile, runtime decline. - **Status**: resolved: universal compile, runtime decline.
- **Resolution**: `direct-streamlocal` compiles wherever `tunnel` compiles; the serve path is **runtime-Unix-gated** — on Unix it serves (`SubstrateAddr::Path` treatment), on every other platform it declines with a clean SSH open-failure (never a half-open). This wins over a unix-only compile split: no per-platform feature fork (the feature graph stays flat and platform-independent — ADR-003), the decline matrix stays uniform across builds, and the decline path is contract-tested in every build like every other decline (fail-closed posture). The research row's "compile-time feature + runtime decline" hybrid collapses to the runtime-decline half — no platform-conditional feature is needed. - **Resolution**: `direct-streamlocal` compiles wherever `tunnel` compiles; the serve path is **runtime-Unix-gated** (on Unix it serves with the `SubstrateAddr::Path` treatment, on every other platform it declines with a clean SSH open-failure, never a half-open). This wins over a unix-only compile split: no per-platform feature fork (the feature graph stays flat and platform-independent, ADR-003), the decline matrix stays uniform across builds, and the decline path is contract-tested in every build like every other decline (fail-closed posture). The research row's "compile-time feature + runtime decline" hybrid collapses to the runtime-decline half; no platform-conditional feature is needed.
- **Cross-references**: ADR-003, [mapping.md](mapping.md) (`direct-streamlocal` row + the `-R` listen flow) - **Cross-references**: ADR-003, [mapping.md](mapping.md) (`direct-streamlocal` row + the `-R` listen flow)
### OQ-12: ~~Client reconnect: does the crate own forward/session restoration?~~ — resolved ### OQ-12: ~~Client reconnect: does the crate own forward/session restoration?~~ (resolved)
- **Origin**: `reverse-runner.md` §4 - **Origin**: `reverse-runner.md` §4
- **Status**: resolved - **Status**: resolved
- **Resolution**: the crate owns lifecycle primitives only — bound-port handles (forward listeners return the actually-bound port, per russh's `tcpip_forward -> u32`), explicit cancellable forward/session handles, and clean disconnects. Reconnect-and-restore policy (rebinding persisted tunnel ports per alkminer's flow) is assembly-owned. Recorded as a constraint in `client.md` §Lifecycle primitives; no reconnect policy is baked into the crate. - **Resolution**: the crate owns lifecycle primitives only: bound-port handles (forward listeners return the actually-bound port, per russh's `tcpip_forward -> u32`), explicit cancellable forward/session handles, and clean disconnects. Reconnect-and-restore policy (rebinding persisted tunnel ports per alkminer's flow) is assembly-owned. Recorded as a constraint in `client.md` §Lifecycle primitives; no reconnect policy is baked into the crate.
- **Cross-references**: [client.md](client.md), `reverse-runner.md` - **Cross-references**: [client.md](client.md), `reverse-runner.md`
## Theme E — future features ## Theme E: future features
### OQ-13: ~~The `sftp` client+server halves when alkfs lands~~ — resolved ### OQ-13: ~~The `sftp` client+server halves when alkfs lands~~ (resolved)
- **Origin**: `mapping-table.md` (sftp rows), `reverse-runner.md`, POC-2 scope notes - **Origin**: `mapping-table.md` (sftp rows), `reverse-runner.md`, POC-2 scope notes
- **Status**: resolved — the alkssh-side answer is fully decided; the remainder belongs to alkfs, not to this tracker. - **Status**: resolved. The alkssh-side answer is fully decided; the remainder belongs to alkfs, not to this tracker.
- **Resolution**: the `sftp` feature ships as the **tested decline placeholder in both directions** from the first build — the feature exists in the graph (ADR-003), advertisement is honest, and the decline contract is tested on both sides (`mapping.md`/`client.md`). That *is* the shipped behavior for as long as alkfs is pending, and it needs no further decision. What remained "pending" was the eventual alkfs-protocol mapping — a decision downstream of alkfs' design (family ADR-002; alkssh follows alkfs' maturity): alkssh writes that mapping and its ADR when the target protocol exists to map, the same way every other row's mapping was decided before implementation. No alkssh decision waits on this; there is no alkssh-side sftp work. - **Resolution**: the `sftp` feature ships as the **tested decline placeholder in both directions** from the first build: the feature exists in the graph (ADR-003), advertisement is honest, and the decline contract is tested on both sides (`mapping.md`/`client.md`). That *is* the shipped behavior for as long as alkfs is pending, and it needs no further decision. What remained "pending" was the eventual alkfs-protocol mapping, a decision downstream of alkfs' design (family ADR-002; alkssh follows alkfs' maturity): alkssh writes that mapping and its ADR when the target protocol exists to map, the same way every other row's mapping was decided before implementation. No alkssh decision waits on this; there is no alkssh-side sftp work.
- **Cross-references**: OQ-01, ADR-003, [client.md](client.md) §op rows, family ADR-002 - **Cross-references**: OQ-01, ADR-003, [client.md](client.md) §op rows, family ADR-002
## Upstream watch items (not OQs) ## Upstream watch items (not OQs)
@@ -130,13 +130,15 @@ noted at the end and carry their work where their owners are.
Recorded so the dependency is visible, with the work carried where Recorded so the dependency is visible, with the work carried where
their owners are: their owners are:
- **PTY-path exit fidelity** — needs alktty/portable-pty work (with - **alkfs landing**: the `sftp` placeholder's eventual replacement
tty/pty-grounded justification) before faithful `exit-signal` can
hold on the PTY row; alkssh adapts with no contract change
(`mapping.md` derivation contract, rule 5; OQ-10).
- **alkfs landing** — the `sftp` placeholder's eventual replacement
(family ADR-002); alkssh writes the sftp mapping + ADR when the (family ADR-002); alkssh writes the sftp mapping + ADR when the
protocol exists (OQ-13). protocol exists (OQ-13).
- **alkvault `Zeroizing`-out `decrypt`** — closes the pre-wrap - ~~**PTY-path exit fidelity**~~: closed upstream (alktty 0.8.2,
plaintext window upstream (OQ-07 / ADR-005 §3); a one-line doc touch review 004's resolution): the PTY waiter now reports signal deaths
for alkssh when it ships. as `−N`; the derivation contract needed no alkssh change
(`mapping.md` rule 5 amended; OQ-10).
- ~~**alkvault `Zeroizing`-out `decrypt`**~~: closed upstream
(alkvault 0.2.0, review 001's resolution; also closes the `rotate`
window and zeroizes the error path): the adapter seam wrap is
dropped, ADR-005 decision 3 + OQ-07 amended as the predicted
one-line doc touch.
+14 -14
View File
@@ -3,7 +3,7 @@ status: reviewed
last_updated: 2026-10-28 last_updated: 2026-10-28
--- ---
# Overview — alkssh # Overview: alkssh
## What this crate is ## What this crate is
@@ -13,18 +13,18 @@ bridges to/from family protocols. It is **a door in both directions,
vanilla on the outside** (vision principle 2; ADR framing in vanilla on the outside** (vision principle 2; ADR framing in
`mapping.md` / `client.md`): `mapping.md` / `client.md`):
- **Server direction** — a door *for vanilla clients*: vanilla `ssh`, - **Server direction**: a door *for vanilla clients*. Vanilla `ssh`,
`git`, and (never for us, by design) sftp tooling connect as they `git`, and (never for us, by design) sftp tooling connect as they
would to any sshd; each accepted SSH channel/request is mapped onto would to any sshd; each accepted SSH channel/request is mapped onto
exactly one family protocol seam and exactly one cargo feature. exactly one family protocol seam and exactly one cargo feature.
Primary current purpose: the alkforge git door. Primary current purpose: the alkforge git door.
- **Client direction** — a door *to vanilla servers*: a family app - **Client direction**: a door *to vanilla servers*. A family app
(the reverse-runner hub) dials vanilla `sshd` (rented fleets, (the reverse-runner hub) dials vanilla `sshd` (rented fleets,
runpod/vast.ai-style boxes) and the ops map family-side onto runpod/vast.ai-style boxes) and the ops map family-side onto
alktty/alktunnels/alkgit types; the runner stays vanilla by design. alktty/alktunnels/alkgit types; the runner stays vanilla by design.
The crate's substance is glue and mapping; no SSH framing is authored The crate's substance is glue and mapping; no SSH framing is authored
in-family (family ADR-003 decision 1) — russh owns the wire, and the in-family (family ADR-003 decision 1); russh owns the wire, and the
pin is ADR-002. pin is ADR-002.
## Position in the family ## Position in the family
@@ -35,17 +35,17 @@ pin is ADR-002.
| SSH channel-type → family-protocol mapping | **this crate** (the spine, `mapping.md` / `client.md`) | | SSH channel-type → family-protocol mapping | **this crate** (the spine, `mapping.md` / `client.md`) |
| Identity (fingerprint → `Identity`) | alkcall `IdentityProvider`, assembly-implemented; never the vault (vault = secrets *we* hold) | | Identity (fingerprint → `Identity`) | alkcall `IdentityProvider`, assembly-implemented; never the vault (vault = secrets *we* hold) |
| Secrets we hold (client dial keys, host keys) | alkvault-backed sources (ADR-005) | | Secrets we hold (client dial keys, host keys) | alkvault-backed sources (ADR-005) |
| The `alk/ssh` ALPN | ADR-001 (edge-termination contract; relay/browser legs have no consumer in scope — OQ-01 resolved) | | The `alk/ssh` ALPN | ADR-001 (edge-termination contract; relay/browser legs have no consumer in scope, OQ-01 resolved) |
| Deployment policy (which features, which ACLs) | the embedding assembly (alkforge) | | Deployment policy (which features, which ACLs) | the embedding assembly (alkforge) |
Composability boundary: alkssh is a door, not a backend — it depends Composability boundary: alkssh is a door, not a backend. It depends
on alkcall types at the wire layer and the mapped crates' session or on alkcall types at the wire layer and the mapped crates' session or
client surfaces, and carries no deployment policy. A downstream app client surfaces, and carries no deployment policy. A downstream app
embeds it, enables features (ADR-003), brings identity/ACL config. embeds it, enables features (ADR-003), brings identity/ACL config.
## Public surface (categories) ## Public surface (categories)
The crate is a library (`[lib]`, no binary — non-goal); the surface The crate is a library (`[lib]`, no binary, a stated non-goal); the surface
categories the phase-1 specs define: categories the phase-1 specs define:
- **Server stack** (`mapping.md`): serve configuration (feature-gated - **Server stack** (`mapping.md`): serve configuration (feature-gated
@@ -55,7 +55,7 @@ categories the phase-1 specs define:
- **Client stack** (`client.md`): dial surface (address, key source, - **Client stack** (`client.md`): dial surface (address, key source,
host-key policy, keepalive/timeout config), the op-row consumers host-key policy, keepalive/timeout config), the op-row consumers
(session exec/pty, tunnel dial, `-R` listen + acceptor), and (session exec/pty, tunnel dial, `-R` listen + acceptor), and
lifecycle primitives (bound-port handles, cancel — OQ-12: no lifecycle primitives (bound-port handles, cancel; OQ-12: no
reconnect policy is owned). reconnect policy is owned).
- **Key sources** (`auth.md`): vault-backed source construction per - **Key sources** (`auth.md`): vault-backed source construction per
ADR-005 for both directions (client keys *and* host keys). ADR-005 for both directions (client keys *and* host keys).
@@ -63,7 +63,7 @@ categories the phase-1 specs define:
at the server; the identity→seam propagation for mapped channels. at the server; the identity→seam propagation for mapped channels.
Feature graph (ADR-003): `session`, `git`, `tunnel`, `sftp` Feature graph (ADR-003): `session`, `git`, `tunnel`, `sftp`
(placeholder) — flat, independent, `default = []` (no capabilities), (placeholder); flat, independent, `default = []` (no capabilities),
two-direction symmetry, fail-closed declines contract-tested on every two-direction symmetry, fail-closed declines contract-tested on every
side of every feature. side of every feature.
@@ -82,17 +82,17 @@ surface notes):
| Keepalives (client direction, cloud fleets) | `keepalive_interval`/`keepalive_max` | 15 s / 3 (reverse-runner evidence: NAT'd runners) | | Keepalives (client direction, cloud fleets) | `keepalive_interval`/`keepalive_max` | 15 s / 3 (reverse-runner evidence: NAT'd runners) |
| Concurrent channels per session | ours (in-crate) + per-identity policy when bridged | ours to cap | | Concurrent channels per session | ours (in-crate) + per-identity policy when bridged | ours to cap |
No unbounded loops, no unbounded buffers — the fail-closed declines No unbounded loops, no unbounded buffers; the fail-closed declines
plus the knobs above are the whole posture. plus the knobs above are the whole posture.
## Non-goals ## Non-goals
Per `vision.md`: no in-family SSH protocol machinery (ever); not an Per `vision.md`: no in-family SSH protocol machinery (ever); not an
sshd (no local accounts, no OpenSSH `authorized_keys` semantics, no sshd (no local accounts, no OpenSSH `authorized_keys` semantics, no
PAM); no sftp serving until alkfs lands (OQ-13 resolved — the feature PAM); no sftp serving until alkfs lands (OQ-13 resolved; the feature
exists as a tested declining placeholder); no binary/CLI; no password exists as a tested declining placeholder); no binary/CLI; no password
or keyboard-interactive auth in either direction; no x11 or agent or keyboard-interactive auth in either direction; no x11 or agent
forwarding at all (not even feature-gated — ADR-003 §5). forwarding at all (not even feature-gated, ADR-003 §5).
## Lifecycle note ## Lifecycle note
@@ -101,13 +101,13 @@ This crate follows the family crate template (alktty root pattern):
API = crate-root re-exports. The dependency set (russh per ADR-002, API = crate-root re-exports. The dependency set (russh per ADR-002,
alkcall types, per-feature sibling deps: alktty/alktunnels/alkgit) and alkcall types, per-feature sibling deps: alktty/alktunnels/alkgit) and
the manifest (`rust-version` 1.89, edition 2024) land at the manifest (`rust-version` 1.89, edition 2024) land at
implementation start — ADR-002 records the manifest decisions. implementation start; ADR-002 records the manifest decisions.
## References ## References
- [README.md](README.md) (index), [open-questions.md](open-questions.md) (OQ-01…13) - [README.md](README.md) (index), [open-questions.md](open-questions.md) (OQ-01…13)
- ADRs 001–006 in [decisions/](decisions/) - ADRs 001–006 in [decisions/](decisions/)
- `docs/research/` — vision, POC findings (1/2/3), russh inventory, - `docs/research/`: vision, POC findings (1/2/3), russh inventory,
auth bridge, mapping table (validated), reverse-runner extraction auth bridge, mapping table (validated), reverse-runner extraction
- Family ADR-003 (the structural decision), family ADR-002 (alkforge; - Family ADR-003 (the structural decision), family ADR-002 (alkforge;
why exclusion matters) why exclusion matters)
+120 -120
View File
@@ -1,11 +1,11 @@
--- ---
status: closed status: closed
last_updated: 2026-10-30 (the plan closed at the publish of alkssh last_updated: 2026-10-30 (the plan closed at the publish of alkssh
0.1.0 — tag `alkssh-v0.1.0`; waves 1–5 decomposed + implemented + 0.1.0, tag `alkssh-v0.1.0`; waves 1–5 decomposed + implemented +
gated 2026-10-28/30) gated 2026-10-28/30)
--- ---
# alkssh — Implementation plan # alkssh: Implementation plan
Wave-based decomposition of the architecture (`docs/architecture/`) Wave-based decomposition of the architecture (`docs/architecture/`)
into units of implementable work. The sibling pattern proven on into units of implementable work. The sibling pattern proven on
@@ -15,7 +15,7 @@ shape of later ones (wave 1's config/error/key-source types are what
every later task implements against; wave 2's server stack is what every later task implements against; wave 2's server stack is what
every mapping composes into), and decomposing only the next wave at a every mapping composes into), and decomposing only the next wave at a
time keeps each session's task set reviewable and lets later time keeps each session's task set reviewable and lets later
decompositions absorb earlier waves' course corrections — reviews decompositions absorb earlier waves' course corrections: reviews
sometimes change things, and tasks downstream then have to be edited, sometimes change things, and tasks downstream then have to be edited,
so decomposing too far ahead is paid rework. so decomposing too far ahead is paid rework.
@@ -26,16 +26,16 @@ branches from stepping on each other). Wave boundaries are also review
boundaries. Each wave's section here carries a **decomposition record** boundaries. Each wave's section here carries a **decomposition record**
added when that wave actually decomposes (not written upfront). added when that wave actually decomposes (not written upfront).
Scope authority: **v0.1.0 publishes the crate** — the complete Scope authority: **v0.1.0 publishes the crate**: the complete
two-direction door (server stack + all mappings, client stack + op two-direction door (server stack + all mappings, client stack + op
rows, key sources, the decline matrix contract-tested per build). The rows, key sources, the decline matrix contract-tested per build). The
sftp decline placeholder *is* shipped behavior (OQ-13 resolved). **The sftp decline placeholder *is* shipped behavior (OQ-13 resolved). **The
assembly stays out of these waves**: alkforge's mounting of assembly stays out of these waves**: alkforge's mounting of
`SshAdapter::handle` at an alkcall `channel/open` boundary, its `SshAdapter::handle` at an alkcall `channel/open` boundary, its
feature/ACL selection, its `IdentityProvider` and serve-config values feature/ACL selection, its `IdentityProvider` and serve-config values
are downstream composition work — the crate ships the door, not the are downstream composition work; the crate ships the door, not the
deployment. No binary, no workspace split (single crate, the alktty deployment. No binary, no workspace split (single crate, the alktty
root pattern; no tracks — one crate, one publish). root pattern; no tracks: one crate, one publish).
## The waves ## The waves
@@ -44,69 +44,69 @@ mappings ∥ client direction) → validation & release.
| Wave | Contents | Depends on | Status | | 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 | | [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 | none | 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 | 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 | 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 | 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 | decomposed |
| [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 | decomposed | | [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 | decomposed |
| [5](#wave-5--validation--release) | Decline-matrix contract suite; real-tool e2e; fuzzing; docs; **the publish** | waves 3 + 4 | published | | [5](#wave-5-validation--release) | Decline-matrix contract suite; real-tool e2e; fuzzing; docs; **the publish** | waves 3 + 4 | published |
## Wave 1 — Foundations ## Wave 1: Foundations
The contract surface in code, engine-blind: everything later waves The contract surface in code, engine-blind: everything later waves
implement *against*, and nothing that needs a live SSH session to implement *against*, and nothing that needs a live SSH session to
test. The alkssh analog of alkgit's seam wave. test. The alkssh analog of alkgit's seam wave.
- **Manifest landing** (ADR-002) — the dependency set lands with - **Manifest landing** (ADR-002): the dependency set lands with
implementation start: `russh = "0.65"` with `aws-lc-rs` (no `flate2`, implementation start: `russh = "0.65"` with `aws-lc-rs` (no `flate2`,
no `rsa` — the tree RC-free except russh's own `ssh-key` pin), no `rsa`; the tree RC-free except russh's own `ssh-key` pin),
`alkcall 0.10` (identity/ACL types at the wire layer), `alkvault 0.1` `alkcall 0.10` (identity/ACL types at the wire layer), `alkvault 0.1`
(the custody model), plus the standing toolkit (`tokio`, `thiserror`, (the custody model), plus the standing toolkit (`tokio`, `thiserror`,
`tracing`, `parking_lot`, `zeroize`). `edition = "2024"`, `tracing`, `parking_lot`, `zeroize`). `edition = "2024"`,
`rust-version = "1.89"`. The ADR-003 feature graph is declared from `rust-version = "1.89"`. The ADR-003 feature graph is declared from
the first commit: `session`, `git`, `tunnel`, `sftp`, `default = []` the first commit: `session`, `git`, `tunnel`, `sftp`, `default = []`
— features gate nothing yet (the decline core references them from `default = []`: features gate nothing yet (the decline core references them from
wave 2); the per-feature sibling deps (`alktty` under `session`, wave 2); the per-feature sibling deps (`alktty` under `session`,
`alktunnels` under `tunnel`, `alkgit` under `git`) land with their `alktunnels` under `tunnel`, `alkgit` under `git`) land with their
first consuming wave, not as dead manifest entries. All family deps first consuming wave, not as dead manifest entries. All family deps
pinned by version, never sibling-checkout paths; pins verified pinned by version, never sibling-checkout paths; pins verified
against crates.io before committing. against crates.io before committing.
- **Error model** — the crate's `thiserror` taxonomy, fail-closed with - **Error model**: the crate's `thiserror` taxonomy, fail-closed with
no catch-alls: the key-source failure matrix as distinct variants no catch-alls: the key-source failure matrix as distinct variants
(ADR-005 decision 4 — locked / absent / decryption failure / unknown (ADR-005 decision 4: locked / absent / decryption failure / unknown
`key_version`, plus decode failure; the vault's taxonomy reshaped, `key_version`, plus decode failure; the vault's taxonomy reshaped,
never collapsed). Auth declines are *dispositions* (`Auth::Reject`), never collapsed). Auth declines are *dispositions* (`Auth::Reject`),
not errors; wire decline reasons are wave-2 shaped. What the config not errors; wire decline reasons are wave-2 shaped. What the config
surface needs beyond this lands with it. surface needs beyond this lands with it.
- **Config surface** — the pure configuration types both directions - **Config surface**: the pure configuration types both directions
implement against: the **serve-config** (feature wiring; OQ-08's two implement against: the **serve-config** (feature wiring; OQ-08's two
assembly-supplied fields — shell argv and exec wrapper argv, each assembly-supplied fields: shell argv and exec wrapper argv, each
`Option`, unset → its row declines, never a fallback; the `Option`, unset → its row declines, never a fallback; the
`IdentityProvider` handle; the host-key source handle; the tunnel `IdentityProvider` handle; the host-key source handle; the tunnel
`ChannelClient` handle; the `Limits`), the **dial-config** (address, `ChannelClient` handle; the `Limits`), the **dial-config** (address,
key source, `HostKeyPolicy` handle, keepalive/inactivity/window key source, `HostKeyPolicy` handle, keepalive/inactivity/window
knobs), and **`Limits`** — the bounded-resources budget model knobs), and **`Limits`**, the bounded-resources budget model
(`overview.md`'s table: in-crate max-concurrent-channels cap, (`overview.md`'s table: in-crate max-concurrent-channels cap,
exec-parse length caps, and the russh knob mapping with the exec-parse length caps, and the russh knob mapping with the
documented default postures — `max_auth_attempts` 10 sized for the documented default postures: `max_auth_attempts` 10 sized for the
counting `none`-probe, keepalives 15 s / 3 client-side per the counting `none`-probe, keepalives 15 s / 3 client-side per the
reverse-runner evidence). reverse-runner evidence).
- **Vault-backed key sources** (ADR-005) — the custody model in code: - **Vault-backed key sources** (ADR-005): the custody model in code:
decrypt-at-use (`vault.decrypt` → `Zeroizing<String>` wrap at the decrypt-at-use (`vault.decrypt` → `Zeroizing<String>` wrap at the
adapter seam → one-time `decode_secret_key(plain, None)`), the adapter seam → one-time `decode_secret_key(plain, None)`), the
client dial-key source and the server host-key load path client dial-key source and the server host-key load path
(decrypt-at-construction; locked vault → no server constructed), (decrypt-at-construction; locked vault → no server constructed),
`EncryptedData` opaque to the crate, plaintext never persists, every `EncryptedData` opaque to the crate, plaintext never persists, every
failure mode mapped to its distinct error variant. failure mode mapped to its distinct error variant.
- **Host-key policy seam** (OQ-05) — the `HostKeyPolicy` trait the - **Host-key policy seam** (OQ-05): the `HostKeyPolicy` trait the
client dial calls with (host, port, presented fingerprint): case-1 client dial calls with (host, port, presented fingerprint): case-1
pins (hard-decline on mismatch) and case-2 assembly policies are the pins (hard-decline on mismatch) and case-2 assembly policies are the
implementor's; the crate ships the fail-closed default (reject implementor's; the crate ships the fail-closed default (reject
unknown) and is otherwise policy-blind — no accept-all built-in. unknown) and is otherwise policy-blind; no accept-all built-in.
- **Fingerprint** (ADR-004) — the `SHA256:` + unpadded base64 - **Fingerprint** (ADR-004): the `SHA256:` + unpadded base64
computation via the `ssh-key` re-export (`HashAlg::Sha256`); the computation via the `ssh-key` re-export (`HashAlg::Sha256`); the
string format is a contract invariant, proven against independent string format is a contract invariant, proven against independent
issuers (`ssh-keygen -lf` vectors, the POC-1 §1 checkpoint shape). issuers (`ssh-keygen -lf` vectors, the POC-1 §1 checkpoint shape).
- **Test doubles** — the in-memory seams every later wave's contract - **Test doubles**: the in-memory seams every later wave's contract
tests run against: an in-memory `IdentityProvider` (registered tests run against: an in-memory `IdentityProvider` (registered
fingerprints → identities), an in-memory vault (encrypt/decrypt fingerprints → identities), an in-memory vault (encrypt/decrypt
round-trip + the failure postures), an in-memory `HostKeyPolicy`, round-trip + the failure postures), an in-memory `HostKeyPolicy`,
@@ -123,10 +123,10 @@ across both directions. The gate reads the landed surface against
ADR-002/003/004/005 and the OQ-08/05 resolutions line by line. ADR-002/003/004/005 and the OQ-08/05 resolutions line by line.
**Decomposition record** (2026-10-28): wave 1 decomposed into eight **Decomposition record** (2026-10-28): wave 1 decomposed into eight
task files under `tasks/implementation/` (flat — one track, no task files under `tasks/implementation/` (flat, one track, no
per-track subdir). Shape: `w1-manifest` first (the ADR-002 dependency per-track subdir). Shape: `w1-manifest` first (the ADR-002 dependency
landing + the ADR-003 feature graph declaration everything lands landing + the ADR-003 feature graph declaration everything lands
inside); then four independent foundations in parallel — inside); then four independent foundations in parallel:
`w1-error-model` (the `KeySourceError` five-variant taxonomy), `w1-error-model` (the `KeySourceError` five-variant taxonomy),
`w1-fingerprint` (the contract-invariant computation + parity `w1-fingerprint` (the contract-invariant computation + parity
vectors), `w1-key-sources` (the ADR-005 custody chain, over manifest + vectors), `w1-key-sources` (the ADR-005 custody chain, over manifest +
@@ -140,7 +140,7 @@ gate) depending on all seven.
Interpretations recorded (flagged for the wave-1 gate): Interpretations recorded (flagged for the wave-1 gate):
- **Per-feature sibling deps land with their first consuming wave**, - **Per-feature sibling deps land with their first consuming wave**,
not in the wave-1 manifest — `alktty`/`alktunnels`/`alkgit` would be not in the wave-1 manifest: `alktty`/`alktunnels`/`alkgit` would be
dead manifest entries in a build where the features gate nothing dead manifest entries in a build where the features gate nothing
yet; the wave-1 manifest lands russh + alkcall + alkvault + the yet; the wave-1 manifest lands russh + alkcall + alkvault + the
toolkit only. The feature *names* are declared from the first toolkit only. The feature *names* are declared from the first
@@ -152,25 +152,25 @@ Interpretations recorded (flagged for the wave-1 gate):
shaped. shaped.
- **The policy verdict type** (bool vs a richer reason-carrying - **The policy verdict type** (bool vs a richer reason-carrying
verdict) is pinned in `w1-host-key-policy` against the russh verdict) is pinned in `w1-host-key-policy` against the russh
`check_server_key` hook shape at this pin — flagged for the gate. `check_server_key` hook shape at this pin; flagged for the gate.
- **The vault-double shape** (wrap alkvault's in-memory construction - **The vault-double shape** (wrap alkvault's in-memory construction
if it ships one, else a local double behind the source trait) is if it ships one, else a local double behind the source trait) is
pinned in `w1-test-doubles` — flagged for the gate. pinned in `w1-test-doubles`; flagged for the gate.
## Wave 2 — The server stack ## Wave 2: The server stack
The russh server substrate every mapping composes into — the door The russh server substrate every mapping composes into (the door
frame, before any door is hung: frame, before any door is hung):
- **`SshAdapter::handle` + `Config` construction** (ADR-001/002) — the - **`SshAdapter::handle` + `Config` construction** (ADR-001/002): the
mount point ADR-001 pins as the `alk/ssh` receiving endpoint: a mount point ADR-001 pins as the `alk/ssh` receiving endpoint: a
generic stream in, the full russh server stack per stream (edge generic stream in, the full russh server stack per stream (edge
termination only — no ALPN code, no relay leg; the pin is a ledger termination only: no ALPN code, no relay leg; the pin is a ledger
entry, already decided). `Config` built from the serve-config: entry, already decided). `Config` built from the serve-config:
honest advertisement (`methods = {publickey}` exactly, ADR-004 honest advertisement (`methods = {publickey}` exactly, ADR-004
decision 4), host keys from the vault-backed source (locked → no decision 4), host keys from the vault-backed source (locked → no
server constructed), the bounded knobs from `Limits`. server constructed), the bounded knobs from `Limits`.
- **The auth bridge** (ADR-004) — the dispositional handler surface: - **The auth bridge** (ADR-004): the dispositional handler surface:
`auth_publickey` fingerprint → `resolve_from_fingerprint` → accept `auth_publickey` fingerprint → `resolve_from_fingerprint` → accept
with the resolved identity / reject fail-closed; ed25519-only with the resolved identity / reject fail-closed; ed25519-only
(non-ed25519 keys declined explicitly); `auth_publickey_offered` (non-ed25519 keys declined explicitly); `auth_publickey_offered`
@@ -179,24 +179,24 @@ frame, before any door is hung:
`auth_openssh_certificate` explicit declines (family contract, not `auth_openssh_certificate` explicit declines (family contract, not
inherited defaults); the username carries no semantics (OQ-03). inherited defaults); the username carries no semantics (OQ-03).
- **Parse-only dispatch + the fail-closed decline core** (ADR-006 as - **Parse-only dispatch + the fail-closed decline core** (ADR-006 as
amended, ADR-003) — session-channel dispatch skeleton (parse path: amended, ADR-003): session-channel dispatch skeleton (parse path:
git-parseable exec → `git`, else → `session`; request-less session git-parseable exec → `git`, else → `session`; request-less session
channels decline), unfeatured channel types decline at open, x11 / channels decline), unfeatured channel types decline at open, x11 /
`auth-agent` always decline (never feature-gated), the sftp `auth-agent` always decline (never feature-gated), the sftp
subsystem decline-with-reason placeholder (OQ-13 — folded here, subsystem decline-with-reason placeholder (OQ-13, folded here
server side), **decline first, alloc never** (the POC-3 stage-A server side), **decline first, alloc never** (the POC-3 stage-A
matrix: unknown repo → collapsed generic denial before any protocol matrix: unknown repo → collapsed generic denial before any protocol
byte). byte).
- **Spawn-and-return plumbing** (ADR-006 decision 4) — the structural - **Spawn-and-return plumbing** (ADR-006 decision 4): the structural
constraint in code: every serving path `tokio::spawn`ed, handlers constraint in code: every serving path `tokio::spawn`ed, handlers
return fast, exit/status reporting from spawned tasks rides the return fast, exit/status reporting from spawned tasks rides the
cloned `Session::handle()`. The reporting *mechanics* land here; the cloned `Session::handle()`. The reporting *mechanics* land here; the
derivation contract's rules are wave-3 session-mapping material. derivation contract's rules are wave-3 session-mapping material.
- **Bounded resources** — the in-crate concurrent-channel cap enforced - **Bounded resources**: the in-crate concurrent-channel cap enforced
at the handler surface, `inactivity_timeout`/`max_auth_attempts` at the handler surface, `inactivity_timeout`/`max_auth_attempts`
wired from `Limits`, per-length exec-parse limits reserved for the wired from `Limits`, per-length exec-parse limits reserved for the
grammar's consumption (wave 3). grammar's consumption (wave 3).
- **Loopback harness** — the in-process client/server test pair the - **Loopback harness**: the in-process client/server test pair the
decline matrix and every mapping's contract tests run through decline matrix and every mapping's contract tests run through
(in-memory doubles from wave 1; real-tool smokes ride the mapping (in-memory doubles from wave 1; real-tool smokes ride the mapping
waves). waves).
@@ -208,61 +208,62 @@ corrects.
green): wave 2 decomposed into six task files under green): wave 2 decomposed into six task files under
`tasks/implementation/` plus the gate. Shape: `w2-server-mount` first `tasks/implementation/` plus the gate. Shape: `w2-server-mount` first
(the `SshAdapter` mount + russh `Config` construction from the (the `SshAdapter` mount + russh `Config` construction from the
serve-config — the substrate, with the Handler skeletoned at its serve-config, the substrate, with the Handler skeletoned at its
fail-closed defaults); then `w2-auth-bridge` (the ADR-004 fail-closed defaults); then `w2-auth-bridge` (the ADR-004
dispositions fill the auth hooks, over the mount + the wave-1 dispositions fill the auth hooks, over the mount + the wave-1
fingerprint path and doubles); then `w2-loopback-harness` (the fingerprint path and doubles); then `w2-loopback-harness` (the
in-process client/server pair — the contract-test vehicle — proving in-process client/server pair, the contract-test vehicle, proving
the doubles live through the stack); then `w2-dispatch-decline-core` the doubles live through the stack); then `w2-dispatch-decline-core`
(parse-only dispatch + the fail-closed decline core, contract-tested (parse-only dispatch + the fail-closed decline core, contract-tested
per feature build through the harness); then `w2-spawn-return` (the per feature build through the harness); then `w2-spawn-return` (the
structural constraint pinned by test + the exit-reporting mechanics structural constraint pinned by test + the exit-reporting mechanics
via the cloned `Session::handle()`); then `w2-bounded-resources` (the via the cloned `Session::handle()`); then `w2-bounded-resources` (the
in-crate channel cap at the handler surface); and `review-wave-2` in-crate channel cap at the handler surface); and `review-wave-2`
(the gate) depending on all six. The chain is deliberately sequential (the gate) depending on all six. The chain is deliberately sequential:
— the auth hooks, the dispatch, and the cap all edit the same the auth hooks, the dispatch, and the cap all edit the same
per-connection Handler impl, so no two tasks hold it in parallel. per-connection Handler impl, so no two tasks hold it in parallel.
Interpretations recorded (flagged for the wave-2 gate): Interpretations recorded (flagged for the wave-2 gate):
- **The git-parseability probe in wave 2 is skeleton-level** (service - **The git-parseability probe in wave 2 is skeleton-level** (service
prefix + path shape) — enough to contract-test the routing decision prefix + path shape), which is enough to contract-test the routing
decision
and the session-build's git-gated decline; the full grammar and the session-build's git-gated decline; the full grammar
(quoted/unquoted paths, service-only → decline-with-reason, (quoted/unquoted paths, service-only → decline-with-reason,
per-length limits) is wave-3's git task, which replaces the probe. per-length limits) is wave-3's git task, which replaces the probe.
- **Wave-2 routing stubs decline cleanly at the family-seam boundary** - **Wave-2 routing stubs decline cleanly at the family-seam boundary**:
— the seams land in wave 3; the stub behavior is documented as the seams land in wave 3; the stub behavior is documented as
skeleton so the routing decisions are contract-tested now. The skeleton so the routing decisions are contract-tested now. The
spawn-and-return mechanics are proven on a stub serving path. spawn-and-return mechanics are proven on a stub serving path.
- **The adapter error surface is shaped in `w2-server-mount`** — the - **The adapter error surface is shaped in `w2-server-mount`**: the
construction error wrapping `KeySourceError` (locked → no server) construction error wrapping `KeySourceError` (locked → no server)
plus the Handler `type Error` (`From<russh::Error>`); this is where plus the Handler `type Error` (`From<russh::Error>`); this is where
the wave-1 note's "wire decline reasons are wave-2 shaped" lands. the wave-1 note's "wire decline reasons are wave-2 shaped" lands.
- **The harness transport and placement are pinned in - **The harness transport and placement are pinned in
`w2-loopback-harness`** (duplex pair vs TCP loopback; module `w2-loopback-harness`** (duplex pair vs TCP loopback; module
placement beside the wave-1 doubles) — flagged for the gate. placement beside the wave-1 doubles); flagged for the gate.
## Wave 3 — Server mappings ## Wave 3: Server mappings
Three parallel branches over the stack (plus the folded sftp row), Three parallel branches over the stack (plus the folded sftp row),
each carrying a minimal real-tool smoke (one clone, one `-L`, one pty each carrying a minimal real-tool smoke (one clone, one `-L`, one pty
session — the POC-3 gates re-landed cheaply); the full matrix is session: the POC-3 gates re-landed cheaply); the full matrix is
wave 5's: wave 5's:
- **session → alktty** — the translation bridge, not a byte pump - **session → alktty**: the translation bridge, not a byte pump
(POC-3 §D.1): `pty_request` args → `TerminalParamsWire` (SSH modes → (POC-3 §D.1): `pty_request` args → `TerminalParamsWire` (SSH modes →
the reserved modes JSON); shell argv / exec wrapper from the the reserved modes JSON); shell argv / exec wrapper from the
serve-config (`wrapper ++ [cmd]`, never naively split; unset → clean serve-config (`wrapper ++ [cmd]`, never naively split; unset → clean
decline — OQ-08); stdin/stdout/stderr chunk translation decline, per OQ-08); stdin/stdout/stderr chunk translation
(`extended_data(1)` for stderr); `window_change_request`/`signal` (`extended_data(1)` for stderr); `window_change_request`/`signal`
rows; `env_request` declined unconditionally (OQ-09); the **exit rows; `env_request` declined unconditionally (OQ-09); the **exit
derivation contract** (`mapping.md` rules 1–6: `code ≥ 0` → derivation contract** (`mapping.md` rules 1–6: `code ≥ 0` →
`exit-status(N)`, negative `−N` → `exit-signal(name)` via the `exit-status(N)`, negative `−N` → `exit-signal(name)` via the
server-side Unix signal-number→name table, `−1` → `exit-status(255)` server-side Unix signal-number→name table, `−1` → `exit-status(255)`
never a signal claim, `core_dumped` never asserted, PTY-path rule-5 never a signal claim, `core_dumped` never asserted, PTY-path rule-5
fidelity limit) — exit-status sent before close, from the spawned fidelity limit); exit-status sent before close, from the spawned
task. task.
- **git → alkgit** — exec-only (ADR-006 decision 2): the parse grammar - **git → alkgit**: exec-only (ADR-006 decision 2), with the parse grammar
(`git-{upload,receive}-pack '/repo'`, quoted/unquoted paths, (`git-{upload,receive}-pack '/repo'`, quoted/unquoted paths,
service-only → decline-with-reason, non-git → parse-then-fail, service-only → decline-with-reason, non-git → parse-then-fail,
per-length limits), unknown-repo → collapsed generic denial before per-length limits), unknown-repo → collapsed generic denial before
@@ -270,10 +271,10 @@ wave 5's:
`serve_duplex_resolved(repo_id, identity, service, duplex, limits, `serve_duplex_resolved(repo_id, identity, service, duplex, limits,
…)` over the channel duplex, process-mode dialect, exit-status both …)` over the channel duplex, process-mode dialect, exit-status both
directions. directions.
- **tunnel → alktunnels route 1** (ADR-006 decision 1) — the - **tunnel → alktunnels route 1** (ADR-006 decision 1): the
adapter-side bridge: the serve-config's `ChannelClient` into the adapter-side bridge taking the serve-config's `ChannelClient` into
family fabric (identity propagated once via `set_identity` so the family fabric (identity propagated once via `set_identity` so
`AccessControl::check` gates the mapped opens — the visible-surface `AccessControl::check` gates the mapped opens, the visible-surface
invariant), `TunnelSession::open_direct` + `take_halves()` pumped invariant), `TunnelSession::open_direct` + `take_halves()` pumped
against the russh channel, accept only after the fabric open against the russh channel, accept only after the fabric open
succeeds (failure → explicit `ConnectFailed` reject), succeeds (failure → explicit `ConnectFailed` reject),
@@ -281,7 +282,7 @@ wave 5's:
clean open-decline off-Unix), the `forwarded-tcpip` handler shape, clean open-decline off-Unix), the `forwarded-tcpip` handler shape,
the `-R` listen flow (`tcpip_forward`/`streamlocal_forward` + the the `-R` listen flow (`tcpip_forward`/`streamlocal_forward` + the
same runtime-Unix gate). same runtime-Unix gate).
- **sftp placeholder** — the decline-with-reason rows (folded into the - **sftp placeholder**: the decline-with-reason rows (folded into the
dispatch task server-side; the client-side placeholder folds into dispatch task server-side; the client-side placeholder folds into
wave 4's acceptor/op-row work; the wave-5 matrix covers both wave 4's acceptor/op-row work; the wave-5 matrix covers both
explicitly). explicitly).
@@ -293,74 +294,73 @@ corrects.
green): wave 3 decomposed into six task files under green): wave 3 decomposed into six task files under
`tasks/implementation/` plus the gate. Shape: `w3-seam-config` first `tasks/implementation/` plus the gate. Shape: `w3-seam-config` first
(the serve-config's per-feature seam handles + the optional (the serve-config's per-feature seam handles + the optional
`alktty`/`alkgit` deps — see the interpretations — the mappings `alktty`/`alkgit` deps; see the interpretations; the mappings
dispatch against them, so it lands before the mappings); then dispatch against them, so it lands before the mappings); then
`w3-decline-tracing` (the wave-2 gate's booked finding: the decline/ `w3-decline-tracing` (the wave-2 gate's booked finding: the decline/
serve-path event vocabulary, landed **before** the mappings so they serve-path event vocabulary, landed **before** the mappings so they
inherit a uniform shape); then the three mapping branches inherit a uniform shape); then the three mapping branches
deliberately sequential — `w3-git-mapping` (the full grammar replaces (deliberately sequential): `w3-git-mapping` (the full grammar replaces
the wave-2 probe + the `serve_duplex_resolved` dispatch + the the wave-2 probe + the `serve_duplex_resolved` dispatch + the
store-and-take handoff it lands first for the session task to reuse), store-and-take handoff it lands first for the session task to reuse),
`w3-tty-session` (the translation bridge + the exit-derivation `w3-tty-session` (the translation bridge + the exit-derivation
contract — the biggest single-row surface), `w3-tunnel-direct` (the contract, the biggest single-row surface), `w3-tunnel-direct` (the
POC-proven route-1 bridge leg + identity `set_identity` propagation), POC-proven route-1 bridge leg + identity `set_identity` propagation),
and `w3-tunnel-listen` (the `-R` listen flow + `forwarded-tcpip` and `w3-tunnel-listen` (the `-R` listen flow + `forwarded-tcpip`
data leg — the POC-untested leg, sized with the stop-and-book data leg: the POC-untested leg, sized with the stop-and-book
provision); and `review-wave-3` (the gate) depending on all six. As provision); and `review-wave-3` (the gate) depending on all six. As
with wave 2, the branch DAG is sequential: the three mappings replace with wave 2, the branch DAG is sequential: the three mappings replace
stubs in the same `Handler` impl and share the decline-matrix test stubs in the same `Handler` impl and share the decline-matrix test
binaries, so no two tasks hold them in parallel — the branches are binaries, so no two tasks hold them in parallel: the branches are
parallel *conceptually* (independent seams), sequential parallel *conceptually* (independent seams), sequential
*logistically*. *logistically*.
Interpretations recorded (flagged for the wave-3 gate): Interpretations recorded (flagged for the wave-3 gate):
- **The serve-config extension is wave 3's first course correction to - **The serve-config extension is wave 3's first course correction to
the wave-1 config surface** — the assembly-supplied alktty/alkgit the wave-1 config surface**: the assembly-supplied alktty/alkgit
seam handles the mappings' seam signatures take (the tty backends seam handles the mappings' seam signatures take (the tty backends
map + selector + ownership provider; the alkgit backend quartet + map + selector + ownership provider; the alkgit backend quartet +
pipeline semaphore + push-options flag) were never carried by the pipeline semaphore + push-options flag) were never carried by the
wave-1 config bullet, and `w3-seam-config` lands them feature-gated wave-1 config bullet, and `w3-seam-config` lands them feature-gated
with the optional sibling deps (`alktty = "0.8.1"`, `alkgit = with the optional sibling deps (`alktty = "0.8.1"`, `alkgit =
"0.1.0"` — crates.io-verified at decomposition) before the "0.1.0"`, crates.io-verified at decomposition) before the
mappings; `alktunnels = "0.4.1"` still lands with its first mappings; `alktunnels = "0.4.1"` still lands with its first
consuming code (`w3-tunnel-direct`) to avoid a dead entry. The consuming code (`w3-tunnel-direct`) to avoid a dead entry. The
unset-declines posture extends to the new handles (unset → the unset-declines posture extends to the new handles (unset → the
owned rows decline even with the feature on). owned rows decline even with the feature on).
- **The server-side sftp bullet is already discharged** — the OQ-13 - **The server-side sftp bullet is already discharged**: the OQ-13
decline rows landed with `w2-dispatch-decline-core` (every subsystem decline rows landed with `w2-dispatch-decline-core` (every subsystem
name declines in every build) and are already contract-tested per name declines in every build) and are already contract-tested per
build in `tests/dispatch_core.rs`; no wave-3 task exists for it — build in `tests/dispatch_core.rs`; no wave-3 task exists for it: the
the client-side placeholder folds into wave 4's acceptor/op-row client-side placeholder folds into wave 4's acceptor/op-row
work and the wave-5 matrix covers both rows explicitly. work and the wave-5 matrix covers both rows explicitly.
- **The `-R` listen flow carries the wave-3 gate's biggest open** — - **The `-R` listen flow carries the wave-3 gate's biggest open**: the fabric-side inbound-forwarded mechanics (who dials the
the fabric-side inbound-forwarded mechanics (who dials the
forwarded op at the door, whether the assembly-supplied forwarded op at the door, whether the assembly-supplied
`Arc<ChannelClient>` composition can carry the door-side accept or `Arc<ChannelClient>` composition can carry the door-side accept or
the serve-config's tunnel handle must grow) is decided in the serve-config's tunnel handle must grow) is decided in
`w3-tunnel-listen` against alkcall 0.10.1 / alktunnels 0.4.1's `w3-tunnel-listen` against alkcall 0.10.1 / alktunnels 0.4.1's
published surfaces; if the topology does not close, the task stops published surfaces; if the topology does not close, the task stops
and books it for the gate — nothing is silently forked or absorbed. and books it for the gate; nothing is silently forked or absorbed.
- **The wave-2 gate's three booked items all have wave-3 homes** — the - **The wave-2 gate's three booked items all have wave-3 homes**: the
decline-path tracing task (`w3-decline-tracing`), the forwarded- decline-path tracing task (`w3-decline-tracing`), the forwarded-
tcpip acceptor vocabulary (`w3-tunnel-listen`, with the client-side tcpip acceptor vocabulary (`w3-tunnel-listen`, with the client-side
accept hook landing in the harness), and the deferred identity- accept hook landing in the harness), and the deferred identity-
landing assertion (each mapping asserts the resolved identity landing assertion (each mapping asserts the resolved identity
reaches its seam; the tunnel branch additionally propagates it via reaches its seam; the tunnel branch additionally propagates it via
`set_identity` — auth.md §Server direction step 4). `set_identity`; see auth.md §Server direction step 4).
## Wave 4 — Client direction ## Wave 4: Client direction
The door outward (independent of wave 3 — shares only wave 1's The door outward (independent of wave 3; shares only wave 1's
foundations), each branch carrying its real-tool smoke where cheap: foundations), each branch carrying its real-tool smoke where cheap:
- **Dial surface** (`client.md`) — connect with address, key source - **Dial surface** (`client.md`): connect with address, key source
(decrypt-at-dial, fail-closed on locked/absent — never env vars), (decrypt-at-dial, fail-closed on locked/absent; never env vars),
host-key policy at `check_server_key` (the wave-1 seam consumed: host-key policy at `check_server_key` (the wave-1 seam consumed:
case-1 pins hard-decline on mismatch, case-2 policies decide), the case-1 pins hard-decline on mismatch, case-2 policies decide), the
bounded config (keepalives 15 s / 3, inactivity, window knobs). The bounded config (keepalives 15 s / 3, inactivity, window knobs). The
authenticated connection handle everything else rides. authenticated connection handle everything else rides.
- **Op rows** — family op → vanilla translation per `client.md`'s - **Op rows**: family op → vanilla translation per `client.md`'s
table: session exec/pty opens (the POC-3 §D.3 mechanics: table: session exec/pty opens (the POC-3 §D.3 mechanics:
`Channel::exec()` not `request_exec`, Success/Failure via `Channel::exec()` not `request_exec`, Success/Failure via
`wait()`-drain not `Err`, `window_change`/`signal` before `wait()`-drain not `Err`, `window_change`/`signal` before
@@ -369,14 +369,14 @@ foundations), each branch carrying its real-tool smoke where cheap:
connection stays usable); git exec consumption (the same grammar the connection stays usable); git exec consumption (the same grammar the
server parses, pointed upstream); the sftp subsystem placeholder server parses, pointed upstream); the sftp subsystem placeholder
decline. decline.
- **The `-R` leg** — `tcpip_forward(address, port) -> bound` (the - **The `-R` leg**: `tcpip_forward(address, port) -> bound` (the
actually-bound port returned), server-initiated `forwarded-tcpip` actually-bound port returned), server-initiated `forwarded-tcpip`
opens routed to the **acceptor registry**: served only under a opens routed to the **acceptor registry**: served only under a
registered feature's acceptor, unregistered/unfeatured accepted registered feature's acceptor, unregistered/unfeatured accepted
opens declined cleanly (never russh's accept-and-drop default), opens declined cleanly (never russh's accept-and-drop default),
late-install across a live connection (the alkminer dynamic), late-install across a live connection (the alkminer dynamic),
`streamlocal_forward` with the runtime-Unix gate. `streamlocal_forward` with the runtime-Unix gate.
- **Lifecycle primitives** (OQ-12) — explicitly cancellable - **Lifecycle primitives** (OQ-12): explicitly cancellable
listener/session handles, clean `Disconnect::ByApplication`; **no listener/session handles, clean `Disconnect::ByApplication`; **no
reconnect or restore policy owned by the crate** (assembly policy; reconnect or restore policy owned by the crate** (assembly policy;
the primitives make it possible). the primitives make it possible).
@@ -390,24 +390,24 @@ the mappings surfaced).
green): wave 4 decomposed into eight task files under green): wave 4 decomposed into eight task files under
`tasks/implementation/` plus the gate. The wave-3 gate's four booked `tasks/implementation/` plus the gate. The wave-3 gate's four booked
findings are folded in as dedicated tasks (the gate's own findings are folded in as dedicated tasks (the gate's own
disposition — "wave-4 material"), not wave-3 backports: the disposition, "wave-4 material"), not wave-3 backports. The
hardening set is server-side and independent of the client chain — hardening set is server-side and independent of the client chain:
`w4-git-exit-close` (booked item 1: the git row's status-before-close `w4-git-exit-close` (booked item 1: the git row's status-before-close
by construction — the seam/shape decided + recorded in-task), by construction, the seam/shape decided + recorded in-task),
`w4-channel-not-held-rows` (item 2: the guard's wire rows on all `w4-channel-not-held-rows` (item 2: the guard's wire rows on all
three rows, per build — test-only), `w4-tty-coverage` (item 3: the three rows, per build, test-only), `w4-tty-coverage` (item 3: the
`channel_close`-fired cancel path + the Closed/Break no-exit-chunk `channel_close`-fired cancel path + the Closed/Break no-exit-chunk
rows + the write_all no-deadline known-limit record), and rows + the write_all no-deadline known-limit record), and
`w4-router-collision` (item 4: the `ListenRouter` token-collision `w4-router-collision` (item 4: the `ListenRouter` token-collision
fail-closed pre-check); the gate's item 5 (the upstream asks) stays fail-closed pre-check); the gate's item 5 (the upstream asks) stays
booked with its owners — no task, nothing absorbed. The client chain booked with its owners: no task, nothing absorbed. The client chain
is deliberately sequential — `w4-dial` first (the russh client stack is deliberately sequential: `w4-dial` first (the russh client stack
+ the connection handle + the client Handler skeleton at fail-closed + the connection handle + the client Handler skeleton at fail-closed
defaults, over an in-process fake-`sshd` server double this task defaults, over an in-process fake-`sshd` server double this task
lands), then `w4-op-rows` (exec/pty, direct-tcpip, git exec over the lands), then `w4-op-rows` (exec/pty, direct-tcpip, git exec over the
shared grammar, the sftp client-side decline — the POC-3 §D.3 shared grammar, the sftp client-side decline with the POC-3 §D.3
mechanics pinned), then `w4-reverse-listen` (the `-R` leg: forward mechanics pinned), then `w4-reverse-listen` (the `-R` leg: forward
requests + the acceptor registry + late-install — the wave's requests + the acceptor registry + late-install, making it the wave's
highest-risk task, growing the harness's `ForwardedAcceptor` highest-risk task, growing the harness's `ForwardedAcceptor`
vocabulary into the crate's real surface), then `w4-lifecycle` (the vocabulary into the crate's real surface), then `w4-lifecycle` (the
OQ-12 primitives); `review-wave-4` (the gate) depends on all eight. OQ-12 primitives); `review-wave-4` (the gate) depends on all eight.
@@ -420,75 +420,75 @@ are scheduled first as cheap remediation.
Interpretations recorded (flagged for the wave-4 gate): Interpretations recorded (flagged for the wave-4 gate):
- **The client Handler skeleton lands fail-closed in `w4-dial`, the - **The client Handler skeleton lands fail-closed in `w4-dial`, the
acceptor registry in `w4-reverse-listen`** — the dial task pins the acceptor registry in `w4-reverse-listen`**: the dial task pins the
decline-all default for server-initiated opens (the ADR-003 §4 decline-all default for server-initiated opens (the ADR-003 §4
rule) and the listen task grows the registry that serves them; the rule) and the listen task grows the registry that serves them; the
split mirrors wave 2's mount-before-mappings shape and keeps the split mirrors wave 2's mount-before-mappings shape and keeps the
dial task's surface reviewable. dial task's surface reviewable.
- **The client-direction test vehicle is an in-process fake `sshd`** - **The client-direction test vehicle is an in-process fake `sshd`**
(a hand-rolled russh server double in `tests/common`) — the (a hand-rolled russh server double in `tests/common`): the
loopback harness drives the *server* direction and is not the loopback harness drives the *server* direction and is not the
vehicle here; real-`sshd` client-direction e2e stays wave-5 vehicle here; real-`sshd` client-direction e2e stays wave-5
territory (the plan's wave-5 bullet), with per-task check-gated territory (the plan's wave-5 bullet), with per-task check-gated
real-tool smokes where cheap (the wave-3 smoke posture). real-tool smokes where cheap (the wave-3 smoke posture).
- **No `DialConfig` course-correction is anticipated** — the wave-1 - **No `DialConfig` course-correction is anticipated**: the wave-1
shape already carries everything `client.md`'s dial surface names; shape already carries everything `client.md`'s dial surface names;
`w4-dial` decides + records if that proves wrong (the `w4-dial` decides + records if that proves wrong (the
`w3-seam-config` pattern), and the acceptor registry is `w3-seam-config` pattern), and the acceptor registry is
client-connection state, not serve-config. client-connection state, not serve-config.
- **The hardening tasks are ordered by single-writer needs, not - **The hardening tasks are ordered by single-writer needs, not
risk** — `w4-channel-not-held-rows` follows `w4-git-exit-close` risk**: `w4-channel-not-held-rows` follows `w4-git-exit-close`
(both touch `tests/git_mapping.rs`) and `w4-tty-coverage` follows (both touch `tests/git_mapping.rs`) and `w4-tty-coverage` follows
the rows task (both touch `tests/tty_mapping.rs`), the wave-3 the rows task (both touch `tests/tty_mapping.rs`), the wave-3
no-two-tasks-hold-one-file lesson applied to the test binaries. no-two-tasks-hold-one-file lesson applied to the test binaries.
## Wave 5 — Validation & release ## Wave 5: Validation & release
- **The decline-matrix contract suite** (`mapping.md`'s table) — every - **The decline-matrix contract suite** (`mapping.md`'s table): every
build (none / `git` / `session` / `tunnel` / `--all-features`) × build (none / `git` / `session` / `tunnel` / `--all-features`) ×
every row it can appear in × both directions, including the sftp every row it can appear in × both directions, including the sftp
placeholder rows and the never-features (x11, `auth-agent`). The placeholder rows and the never-features (x11, `auth-agent`). The
fail-closed invariant is contract territory on both sides of every fail-closed invariant is contract territory on both sides of every
feature (AGENTS.md conventions 5–6). feature (AGENTS.md conventions 5–6).
- **Real-tool e2e** — the POC-3 gates re-landed against the shipped - **Real-tool e2e**: the POC-3 gates re-landed against the shipped
crate: OpenSSH (pty/shell/exec, `-L`, `-R`, `-D`'s `direct-tcpip` crate: OpenSSH (pty/shell/exec, `-L`, `-R`, `-D`'s `direct-tcpip`
leg, decline observability), git 2.43+ (clone/push through the git leg, decline observability), git 2.43+ (clone/push through the git
mapping), curl through a `-D` proxy; client direction against a real mapping), curl through a `-D` proxy; client direction against a real
`sshd` (the reverse-runner shape: exec, direct-tcpip dial, `-R` `sshd` (the reverse-runner shape: exec, direct-tcpip dial, `-R`
listen + acceptor). listen + acceptor).
- **Fuzzing** — the family standing rule (anything worth fuzzing gets - **Fuzzing**: the family standing rule (anything worth fuzzing gets
fuzzed) on the crate's ingress surfaces: the exec parse grammar, the fuzzed) on the crate's ingress surfaces: the exec parse grammar, the
fingerprint format path, the key-decode path, the TerminalParamsWire fingerprint format path, the key-decode path, the TerminalParamsWire
conversion. The `fuzz/` sidecar convention (detached runner, corpus conversion. The `fuzz/` sidecar convention (detached runner, corpus
gate joining the verification list). gate joining the verification list).
- **Release readiness** — crate docs (public-API rustdoc - **Release readiness**: crate docs (public-API rustdoc
warning-free; docs honesty), the feature matrix (`default`, each warning-free; docs honesty), the feature matrix (`default`, each
feature over it, `--all-features`), CHANGELOG/README, publish feature over it, `--all-features`), CHANGELOG/README, publish
dry-run, then **the publish of `alkssh 0.1`** — the gate review that dry-run, then **the publish of `alkssh 0.1`**, the gate review that
closes the plan. closes the plan.
**Decomposition record** (2026-10-30, after the wave-4 gate closed **Decomposition record** (2026-10-30, after the wave-4 gate closed
green — the gate's verdict: "wave 5 decomposition is unblocked and green, with the gate's verdict: "wave 5 decomposition is unblocked and
should absorb nothing beyond its own plan"; the wave-3 gate's item-5 should absorb nothing beyond its own plan"; the wave-3 gate's item-5
upstream asks verified still booked with their owners, no task): wave upstream asks verified still booked with their owners, no task): wave
5 decomposed into seven task files under `tasks/implementation/`. 5 decomposed into seven task files under `tasks/implementation/`.
Shape: four independent implementation tasks over the landed waves — Shape: four independent implementation tasks over the landed waves:
`w5-decline-matrix` (the coverage audit of `mapping.md`'s matrix `w5-decline-matrix` (the coverage audit of `mapping.md`'s matrix
table cell by cell + the gap rows in the existing per-build binaries table cell by cell + the gap rows in the existing per-build binaries
+ the matrix runner joining the verification list), `w5-e2e-server` + the matrix runner joining the verification list), `w5-e2e-server`
(OpenSSH pty/shell/exec/`-L`/`-R`/`-D`+curl, decline observability, (OpenSSH pty/shell/exec/`-L`/`-R`/`-D`+curl, decline observability,
git clone **and** push — a dedicated new e2e binary, the wave-3 git clone **and** push in a dedicated new e2e binary, the wave-3
smokes re-landed/extended), `w5-e2e-client` (the reverse-runner shape smokes re-landed/extended), `w5-e2e-client` (the reverse-runner shape
against a spawned real `sshd` — the sshd fixture, exec/direct-tcpip/ against a spawned real `sshd`: the sshd fixture, exec/direct-tcpip/
`-R`+acceptor, the decline rows), and `w5-fuzz-harness` (the `fuzz/` `-R`+acceptor, the decline rows), and `w5-fuzz-harness` (the `fuzz/`
sidecar per the alkgit convention: four targets — exec_parse, sidecar per the alkgit convention: four targets (exec_parse,
fingerprint, key_decode, terminal_params — the shared-library corpus fingerprint, key_decode, terminal_params), the shared-library corpus
gate joining the verification list, the detached runner, the root gate joining the verification list, the detached runner, the root
workspace `exclude`); then `w5-release-readiness` (rustdoc workspace `exclude`); then `w5-release-readiness` (rustdoc
warning-free × builds, README lands, the CHANGELOG 0.1.0 entry, the warning-free × builds, README lands, the CHANGELOG 0.1.0 entry, the
feature matrix, the publish dry-run + package-policy file-list check, feature matrix, the publish dry-run + package-policy file-list check,
the freeze inventory recorded for the gate) over all four; the freeze inventory recorded for the gate) over all four;
`review-wave-5` (the release gate — a **pure review**) over all five; `review-wave-5` (the release gate, a **pure review**) over all five;
and `w5-publish` (the publish checklist: manifest sanity, the full and `w5-publish` (the publish checklist: manifest sanity, the full
verification set, the dry-run, the crate-qualified `alkssh-v0.1.0` verification set, the dry-run, the crate-qualified `alkssh-v0.1.0`
tag, `cargo publish`, the plan's closure recorded) over the gate. tag, `cargo publish`, the plan's closure recorded) over the gate.
@@ -496,17 +496,17 @@ The four implementation tasks are mutually independent by
construction (disjoint files: the matrix task owns the existing construction (disjoint files: the matrix task owns the existing
per-build binaries, the e2e tasks own new binaries + their own per-build binaries, the e2e tasks own new binaries + their own
fixture modules, the fuzz task owns `fuzz/` + at most fixture modules, the fuzz task owns `fuzz/` + at most
`#[doc(hidden)]` exposures) — parallel *conceptually*; any src/ `#[doc(hidden)]` exposures), parallel *conceptually*; any src/
fix either finds is fixed in-task, with the gate as the merge point. fix either finds is fixed in-task, with the gate as the merge point.
Interpretations recorded (flagged for the wave-5 gate): Interpretations recorded (flagged for the wave-5 gate):
- **The gate-then-publish split** — the plan's release-readiness - **The gate-then-publish split**: the plan's release-readiness
bullet folds the publish into release readiness, but the bullet folds the publish into release readiness, but the
decomposition follows the alkgit pattern: `review-wave-5` stays a decomposition follows the alkgit pattern: `review-wave-5` stays a
pure review and `w5-publish` is its own task (the publish is a pure review and `w5-publish` is its own task (the publish is a
one-way door; it executes only over a green gate). one-way door; it executes only over a green gate).
- **The e2e vehicle/placement** — dedicated new test binaries - **The e2e vehicle/placement**: dedicated new test binaries
(`tests/e2e_server.rs` / `tests/e2e_client.rs`, reusing (`tests/e2e_server.rs` / `tests/e2e_client.rs`, reusing
`tests/common`) rather than extensions of the wave-3/4 mapping `tests/common`) rather than extensions of the wave-3/4 mapping
binaries, keeping those single-writer for the matrix task; the binaries, keeping those single-writer for the matrix task; the
@@ -519,23 +519,23 @@ Interpretations recorded (flagged for the wave-5 gate):
fuzz-visible exposures per target) is decided + recorded in fuzz-visible exposures per target) is decided + recorded in
`w5-fuzz-harness` (the alkgit pattern); the sidecar is excluded `w5-fuzz-harness` (the alkgit pattern); the sidecar is excluded
from the root single-member workspace and never a root dep. from the root single-member workspace and never a root dep.
- **The tag convention** — crate-qualified `alkssh-v0.1.0` (the - **The tag convention**: crate-qualified `alkssh-v0.1.0` (the
alkgit convention; a bare `v0.1.0` would be ambiguous across the alkgit convention; a bare `v0.1.0` would be ambiguous across the
family's multi-crate repos). Pinned in `w5-publish`; flagged for family's multi-crate repos). Pinned in `w5-publish`; flagged for
the gate to confirm. the gate to confirm.
- **No version bump** — the manifest already carries 0.1.0; the - **No version bump**: the manifest already carries 0.1.0; the
publish task verifies rather than bumps. publish task verifies rather than bumps.
## Plan closure (2026-10-30) ## Plan closure (2026-10-30)
The plan closed at the publish of **`alkssh 0.1.0`** — the publish The plan closed at the publish of **`alkssh 0.1.0`**: the publish
commit, tagged **`alkssh-v0.1.0`** (the crate-qualified alkgit tag commit, tagged **`alkssh-v0.1.0`** (the crate-qualified alkgit tag
convention) and pushed to `origin/main`. Scope authority discharged: convention) and pushed to `origin/main`. Scope authority discharged:
the complete two-direction door shipped (server stack + all mappings, the complete two-direction door shipped (server stack + all mappings,
client stack + op rows, key sources, host-key policy seam, the client stack + op rows, key sources, host-key policy seam, the
decline matrix contract-tested per build per `scripts/matrix.sh` — decline matrix contract-tested per build per `scripts/matrix.sh`
see AGENTS.md's verification list), the assembly stays out (seams (see AGENTS.md's verification list), the assembly stays out (seams
only — identity/ACL/fabric policy is downstream composition), no only; identity/ACL/fabric policy is downstream composition), no
binary, single crate. All five review gates closed green (waves 1–4 binary, single crate. All five review gates closed green (waves 1–4
2026-10-28/29; the wave-5 release gate 2026-10-30). 2026-10-28/29; the wave-5 release gate 2026-10-30).
@@ -555,9 +555,9 @@ mapping.md's correlation-miss event-vocabulary nuance) and the
## References ## References
- `docs/architecture/` — overview, mapping, client, auth, - `docs/architecture/`: overview, mapping, client, auth,
open-questions (all reviewed 2026-10-28), ADR-001…006 open-questions (all reviewed 2026-10-28), ADR-001…006
- `docs/research/` — vision, russh inventory, auth bridge, mapping - `docs/research/`: vision, russh inventory, auth bridge, mapping
table, POC-1/2/3 findings, reverse-runner extraction table, POC-1/2/3 findings, reverse-runner extraction
- Family ADR-003 (the structural decision); the alkgit implementation - Family ADR-003 (the structural decision); the alkgit implementation
plan (the pattern this follows) plan (the pattern this follows)
+27 -27
View File
@@ -1,4 +1,4 @@
# Auth bridge — russh auth ↔ alkcall identity # Auth bridge: russh auth ↔ alkcall identity
Status: Draft (phase 0 research). Sources: alkcall 0.10.1 source Status: Draft (phase 0 research). Sources: alkcall 0.10.1 source
(`src/core/auth.rs`, `src/core/types.rs`, `src/protocol/dispatch.rs`), (`src/core/auth.rs`, `src/core/types.rs`, `src/protocol/dispatch.rs`),
@@ -31,7 +31,7 @@ pub trait IdentityProvider: Send + Sync + 'static {
} }
``` ```
`IdentityProvider` is a trait only — implementations are supplied by `IdentityProvider` is a trait only; implementations are supplied by
the assembly layer (alkcall ships `NoopIdentityProvider`, which the assembly layer (alkcall ships `NoopIdentityProvider`, which
resolves nothing). alkhttp's server resolves tokens through exactly resolves nothing). alkhttp's server resolves tokens through exactly
this one backend for every inbound surface (bearer + door-Basic, this one backend for every inbound surface (bearer + door-Basic,
@@ -53,13 +53,13 @@ already-verified key.
1. russh `auth_publickey(user, public_key)` fires. 1. russh `auth_publickey(user, public_key)` fires.
2. alkssh computes `public_key.fingerprint()` via the `ssh-key` re- 2. alkssh computes `public_key.fingerprint()` via the `ssh-key` re-
export (`HashAlg::Sha256`, `SHA256:base64` OpenSSH format) — export (`HashAlg::Sha256`, `SHA256:base64` OpenSSH format):
**fingerprint-format parity with the assembly layer's registered **fingerprint-format parity with the assembly layer's registered
identities is a contract invariant and a POC-1 checkpoint** (the identities is a contract invariant and a POC-1 checkpoint** (the
format is stable and self-describing, but the registration path is format is stable and self-describing, but the registration path is
assembly-owned, so the exact string compare must be verified, not assembly-owned, so the exact string compare must be verified, not
assumed). assumed).
3. `IdentityProvider::resolve_from_fingerprint(fp)` — `Some(identity)` 3. `IdentityProvider::resolve_from_fingerprint(fp): `Some(identity)`
→ `Result<Auth, _>::Authenticate { .. }`; `None` → decline → `Result<Auth, _>::Authenticate { .. }`; `None` → decline
(`Reject`). The user name string is **ignored for identity** (it is (`Reject`). The user name string is **ignored for identity** (it is
not the family identity model); whether the username is checked at not the family identity model); whether the username is checked at
@@ -69,7 +69,7 @@ already-verified key.
4. On success, the resolved identity rides the session: 4. On success, the resolved identity rides the session:
`auth_succeeded(session)` attaches it to alkssh's per-session `auth_succeeded(session)` attaches it to alkssh's per-session
state; when the session is bridged over alkcall, the identity is state; when the session is bridged over alkcall, the identity is
pushed with the once-only `Connection::set_identity(identity)` — pushed with the once-only `Connection::set_identity(identity)`,
after which `AccessControl::check(peer_identity)` gates every after which `AccessControl::check(peer_identity)` gates every
mapped channel open exactly as in alkhttp (visible-surface = mapped channel open exactly as in alkhttp (visible-surface =
authorized-surface). Unresolvable keys decline fail-closed; the authorized-surface). Unresolvable keys decline fail-closed; the
@@ -81,20 +81,20 @@ ownership unverified there); the constant-rejection-time contract
(`Config::auth_rejection_time`) plus `Config::max_auth_attempts` (`Config::auth_rejection_time`) plus `Config::max_auth_attempts`
covers the timing/DoS posture. Note russh's own doc warning: seeing an covers the timing/DoS posture. Note russh's own doc warning: seeing an
unknown key at the `offered` stage should not count toward the attempt unknown key at the `offered` stage should not count toward the attempt
limit — the offered hook's disposition is "accept the offer, let limit; the offered hook's disposition is "accept the offer, let
`auth_publickey` decide", which keeps counting honest. `auth_publickey` decide", which keeps counting honest.
### Auth-method disposition (fail-closed matrix) ### Auth-method disposition (fail-closed matrix)
| russh hook | Disposition | Rationale | | russh hook | Disposition | Rationale |
|---|---|---| |---|---|---|
| `auth_publickey` | Implement — the only method | The family identity model is keys; fingerprint → `Identity` | | `auth_publickey` | Implement (the only method) | The family identity model is keys; fingerprint → `Identity` |
| `auth_publickey_offered` | Implement (accept-and-let-decide) | Keeps `max_auth_attempts` honest | | `auth_publickey_offered` | Implement (accept-and-let-decide) | Keeps `max_auth_attempts` honest |
| `auth_none` | Decline (implement explicitly) | Fail-closed is ours to enforce | | `auth_none` | Decline (implement explicitly) | Fail-closed is ours to enforce |
| `auth_password` | Decline (implement explicitly) | No password concept in family identity | | `auth_password` | Decline (implement explicitly) | No password concept in family identity |
| `auth_keyboard_interactive` | Decline (implement explicitly) | Same | | `auth_keyboard_interactive` | Decline (implement explicitly) | Same |
| `auth_openssh_certificate` | Decline initially (OQ candidate) | OpenSSH CA trust model ≠ family key registry; revisit on a use case | | `auth_openssh_certificate` | Decline initially (OQ candidate) | OpenSSH CA trust model ≠ family key registry; revisit on a use case |
| `authentication_banner` | Assembly-provided (e.g. alkforge notice) or none | — | | `authentication_banner` | Assembly-provided (e.g. alkforge notice) or none | (n/a) |
`Config::methods: MethodSet` must advertise exactly what is implemented `Config::methods: MethodSet` must advertise exactly what is implemented
(honest advertisement = only `publickey` in every current configuration). (honest advertisement = only `publickey` in every current configuration).
@@ -103,7 +103,7 @@ limit — the offered hook's disposition is "accept the offer, let
corrects an earlier working assumption):** the default impls of corrects an earlier working assumption):** the default impls of
`auth_none`, `auth_password`, `auth_keyboard_interactive`, `auth_none`, `auth_password`, `auth_keyboard_interactive`,
`auth_publickey`, and `auth_openssh_certificate` are *reject* `auth_publickey`, and `auth_openssh_certificate` are *reject*
(`Auth::reject()`) in 0.64.1 — only `auth_publickey_offered` defaults (`Auth::reject()`) in 0.64.1; only `auth_publickey_offered` defaults
to accept. The fail-closed posture is therefore largely inherited at to accept. The fail-closed posture is therefore largely inherited at
this pin, but every hook is still implemented explicitly as a family this pin, but every hook is still implemented explicitly as a family
contract (a future pin bump could alter defaults; the pin is a one-way contract (a future pin bump could alter defaults; the pin is a one-way
@@ -116,7 +116,7 @@ client *signs* with a private key; the server verifies. alkssh's client
side therefore holds secret material, and the family rule applies: that side therefore holds secret material, and the family rule applies: that
material comes from alkvault-backed sources, never env vars, never material comes from alkvault-backed sources, never env vars, never
plaintext at rest (family ADR-002 decision 5). The guiding framing: plaintext at rest (family ADR-002 decision 5). The guiding framing:
**a held SSH key is just another bearer-token-class secret** — opaque **a held SSH key is just another bearer-token-class secret**: opaque
bytes the family holds, vault-encrypted at rest, decrypted at use. bytes the family holds, vault-encrypted at rest, decrypted at use.
### The alkhttp pattern being mirrored (alkhttp ADR-014) ### The alkhttp pattern being mirrored (alkhttp ADR-014)
@@ -127,28 +127,28 @@ injects the secret as an alkcall `Capabilities` entry at registration,
and the adapter reads it fail-closed at use time (`Capabilities::get` and the adapter reads it fail-closed at use time (`Capabilities::get`
→ absent → loud internal error, never an unauthenticated send). The → absent → loud internal error, never an unauthenticated send). The
alkssh translation: the client adapter takes *key sources*, not static alkssh translation: the client adapter takes *key sources*, not static
keys — the assembly layer supplies an alkvault-backed source (the keys: the assembly layer supplies an alkvault-backed source (the
encrypted-at-rest blob); the adapter decodes it into a russh-usable encrypted-at-rest blob); the adapter decodes it into a russh-usable
key at dial time; absent/locked vault → fail-closed decline, no key at dial time; absent/locked vault → fail-closed decline, no
anonymous dial. anonymous dial.
### The gap: alkvault-backed key storage ### The gap: alkvault-backed key storage
alkvault's role for SSH keys is **at-rest encryption, nothing more** — alkvault's role for SSH keys is **at-rest encryption, nothing more**:
it is to an SSH client key what it is to every outgoing secret: a it is to an SSH client key what it is to every outgoing secret: a
secret store. Held key material is treated exactly like a random secret store. Held key material is treated exactly like a random
bearer token — opaque bytes the family holds, vault-encrypted at rest, bearer token: opaque bytes the family holds, vault-encrypted at rest,
decrypted at use (the alkhttp ADR-014 model: vault is assembly-layer decrypted at use (the alkhttp ADR-014 model: vault is assembly-layer
only, secrets are zeroized `Secret`s at the adapter, never env vars). only, secrets are zeroized `Secret`s at the adapter, never env vars).
alkvault's *derivation* surface (`derive_ed25519`, SLIP-0010 paths) is alkvault's *derivation* surface (`derive_ed25519`, SLIP-0010 paths) is
a different feature of that crate and is **not** the SSH-keys path — a different feature of that crate and is **not** the SSH-keys path:
there is no derivation story here, and no vault-custody design where there is no derivation story here, and no vault-custody design where
key bytes never leave the vault. key bytes never leave the vault.
The bridge that does not exist yet, and is alkssh phase 0/1 design The bridge that does not exist yet, and is alkssh phase 0/1 design
work: work:
- **At-rest shape**: what byte format sits in the vault store — - **At-rest shape**: what byte format sits in the vault store:
OpenSSH private-key encoding (the format `ssh-key` itself supports OpenSSH private-key encoding (the format `ssh-key` itself supports
with a passphrase) is the natural candidate; alkvault `encrypt`/ with a passphrase) is the natural candidate; alkvault `encrypt`/
`decrypt` (`EncryptedData`) is the outer wrapper. `decrypt` (`EncryptedData`) is the outer wrapper.
@@ -156,13 +156,13 @@ work:
(`decode_secret_key` for OpenSSH encoding) at dial/construction (`decode_secret_key` for OpenSSH encoding) at dial/construction
time, held as a zeroized `Secret`-like value for the dial's time, held as a zeroized `Secret`-like value for the dial's
lifetime. russh's `Signer` trait was considered as an lifetime. russh's `Signer` trait was considered as an
extraction-avoidance shape; withdrawn — the at-rest model implies extraction-avoidance shape; withdrawn, since the at-rest model implies
the bytes are handled client-side at use time anyway (as every the bytes are handled client-side at use time anyway (as every
bearer-token-like secret is), so a one-time decode at the adapter bearer-token-like secret is), so a one-time decode at the adapter
boundary is the honest shape. Lifecycle questions (zeroization boundary is the honest shape. Lifecycle questions (zeroization
bounds, locked-vault behavior → fail-closed no-dial) are POC-2's bounds, locked-vault behavior → fail-closed no-dial) are POC-2's
checkpoints. checkpoints.
- **Source shape**: the adapter takes *key sources*, not static keys — - **Source shape**: the adapter takes *key sources*, not static keys:
an alknet-`main`-shaped `KeySource` seam an alknet-`main`-shaped `KeySource` seam
(`{ File, Memory }`-equivalent) backed by vault-encrypted material (`{ File, Memory }`-equivalent) backed by vault-encrypted material
at the assembly layer, mirroring alkhttp's capability injection. at the assembly layer, mirroring alkhttp's capability injection.
@@ -171,14 +171,14 @@ work:
comparison; note alkvault's `encrypt`/`decrypt` speak `String` only, comparison; note alkvault's `encrypt`/`decrypt` speak `String` only,
so binary formats need a base64 layer); one-time so binary formats need a base64 layer); one-time
`decode_secret_key` at dial confirmed; ssh-key zeroizes ed25519 key `decode_secret_key` at dial confirmed; ssh-key zeroizes ed25519 key
bytes on drop but the vault's `decrypt` returns a plain `String` — bytes on drop but the vault's `decrypt` returns a plain `String`,
the adapter owns plaintext zeroization at the seam (`Zeroizing` so the adapter owns plaintext zeroization at the seam (`Zeroizing`
wrap) [OQ seed]; the `Signer` seam is dismissed with evidence (no wrap) [OQ seed]; the `Signer` seam is dismissed with evidence (no
provided impl in 0.64.1; works when hand-rolled; in-process vault provided impl in 0.64.1; works when hand-rolled; in-process vault
gains nothing). Locked/absent/tampered/rotation all fail closed gains nothing). Locked/absent/tampered/rotation all fail closed
through distinct, mappable error shapes. through distinct, mappable error shapes.
- **Scope note**: the vault is **client-side only**. The server side - **Scope note**: the vault is **client-side only**. The server side
never touches the vault for *peer* identity — it receives clients' never touches the vault for *peer* identity: it receives clients'
public keys and resolves fingerprints through the assembly layer's public keys and resolves fingerprints through the assembly layer's
`IdentityProvider`. The server's own host key, though, is also a `IdentityProvider`. The server's own host key, though, is also a
held secret and uses the same at-rest model. Vault material is for held secret and uses the same at-rest model. Vault material is for
@@ -187,9 +187,9 @@ work:
### Host keys and `check_server_key` ### Host keys and `check_server_key`
The server's host identity (`Config::keys: Vec<PrivateKey>`, The server's host identity (`Config::keys: Vec<PrivateKey>`,
`certificates`) is assembly-configured — a held secret exactly like a `certificates`) is assembly-configured, a held secret exactly like a
client key, same at-rest model as above. The client's client key, same at-rest model as above. The client's
`Handler::check_server_key` defaults to *reject all* — russh forces a `Handler::check_server_key` defaults to *reject all*: russh forces a
deliberate host-key policy. Family posture (no TOFU): pin expected deliberate host-key policy. Family posture (no TOFU): pin expected
host fingerprints at the assembly layer; `check_known_hosts` exists in host fingerprints at the assembly layer; `check_known_hosts` exists in
russh, but the family identity registry is likely the better pin store russh, but the family identity registry is likely the better pin store
@@ -200,7 +200,7 @@ for the ADR.
**Post-POC refinement (client-direction host-policy cases; see **Post-POC refinement (client-direction host-policy cases; see
`reverse-runner.md` and OQ-05 in the architecture tracker):** the `reverse-runner.md` and OQ-05 in the architecture tracker):** the
no-TOFU rule is a rule about trust *anchors*, not an absolute. Two no-TOFU rule is a rule about trust *anchors*, not an absolute. Two
client cases: (1) hosts the assembly controls or can provision — the client cases: (1) hosts the assembly controls or can provision; the
host key is knowable beforehand (family-built runners mint theirs at host key is knowable beforehand (family-built runners mint theirs at
assembly time) → pinned, hard-decline on mismatch; (2) hosts that are assembly time) → pinned, hard-decline on mismatch; (2) hosts that are
unknown until first contact (rental-fleet vanilla `sshd`) → the unknown until first contact (rental-fleet vanilla `sshd`) → the
@@ -214,16 +214,16 @@ policy-blind.
- alkcall: `src/core/auth.rs` (Identity/AuthToken/AuthContext/ - alkcall: `src/core/auth.rs` (Identity/AuthToken/AuthContext/
IdentityProvider verbatim above), `src/core/types.rs` (`set_identity` IdentityProvider verbatim above), `src/core/types.rs` (`set_identity`
once-only), `src/protocol/dispatch.rs` (`resolve_identity` — token once-only), `src/protocol/dispatch.rs` (`resolve_identity`: token
wins per-request, else connection identity), `src/registry/spec.rs` wins per-request, else connection identity), `src/registry/spec.rs`
(`AccessControl::check`) (`AccessControl::check`)
- alkcall ADRs: 002 (ProtocolHandler, hybrid auth completion — - alkcall ADRs: 002 (ProtocolHandler, hybrid auth completion,
"handlers must not assume AuthContext is fully resolved"), 003 "handlers must not assume AuthContext is fully resolved"), 003
(auth-as-shared-core; the SshAdapter credential-table row), 006 (auth-as-shared-core; the SshAdapter credential-table row), 006
(AuthContext shape/set_identity), 010 (secret-material flow) (AuthContext shape/set_identity), 010 (secret-material flow)
- alkhttp ADR-014 (secret material flow + capability injection — the - alkhttp ADR-014 (secret material flow + capability injection, the
client-side pattern), ADR-073 (one backend behind every door surface) client-side pattern), ADR-073 (one backend behind every door surface)
- alkvault 0.1.0: `src/service.rs` (`VaultServiceHandle` — for SSH - alkvault 0.1.0: `src/service.rs` (`VaultServiceHandle`: for SSH
keys, the `encrypt`/`decrypt`/`EncryptedData` at-rest surface is the keys, the `encrypt`/`decrypt`/`EncryptedData` at-rest surface is the
relevant one; the derivation surface is not on this path), relevant one; the derivation surface is not on this path),
`src/protocol.rs` (`EncryptedData`) `src/protocol.rs` (`EncryptedData`)
+30 -30
View File
@@ -1,9 +1,9 @@
# Mapping table — SSH channel types × family protocol seams # Mapping table: SSH channel types × family protocol seams
Status: Draft (phase 0). The crate's spine. Every row maps a Status: Draft (phase 0). The crate's spine. Every row maps a
russh server-side entry point onto exactly one family protocol seam russh server-side entry point onto exactly one family protocol seam
plus exactly one cargo feature. Names below are *feature-name plus exactly one cargo feature. Names below are *feature-name
candidates*, not decisions — the feature graph is phase 1 ADR candidates*, not decisions; the feature graph is phase 1 ADR
territory. All downstream surfaces verified against published sibling territory. All downstream surfaces verified against published sibling
source (alktty 0.8.1, alktunnels, alksocks, alkgit 0.1.0, alkcall 0.10). source (alktty 0.8.1, alktunnels, alksocks, alkgit 0.1.0, alkcall 0.10).
@@ -16,19 +16,19 @@ run are marked **[POC-3 corrected]** below.
## The shape that makes this work ## The shape that makes this work
Every family protocol crate already exposes a generic duplex-stream or Every family protocol crate already exposes a generic duplex-stream or
`Connection`-serving entry — no crate needs to know SSH exists: `Connection`-serving entry, so no crate needs to know SSH exists:
| Crate | Serve-over-a-stream entry | Signature shape | | Crate | Serve-over-a-stream entry | Signature shape |
|---|---|---| |---|---|---|
| alktty | `drive_session[_pre_negotiated]` | `(impl AsyncWrite, impl AsyncRead, backends, identity)` | | alktty | `drive_session[_pre_negotiated]` | `(impl AsyncWrite, impl AsyncRead, backends, identity)` |
| alkgit | `serve_duplex_resolved` | `(repo_id, identity, service, T: AsyncRead+AsyncWrite, limits, backend traits)` | | alkgit | `serve_duplex_resolved` | `(repo_id, identity, service, T: AsyncRead+AsyncWrite, limits, backend traits)` |
| alksocks | `serve_socks5_over` | `(T: AsyncRead+AsyncWrite+Unpin, dial, egress, accept, can_bind)` | | alksocks | `serve_socks5_over` | `(T: AsyncRead+AsyncWrite+Unpin, dial, egress, accept, can_bind)` |
| alktunnels | — **no serve-over-stream entry; see absence note** | consumer wrappers / open-op registrations instead | | alktunnels | **no serve-over-stream entry; see absence note** | consumer wrappers / open-op registrations instead |
russh channels convert to duplex via `Channel::{into_stream, split, russh channels convert to duplex via `Channel::{into_stream, split,
make_reader/make_writer}` (`ChannelStream`/com halves). So each make_reader/make_writer}` (`ChannelStream`/com halves). So each
mapping is: accept-or-decline the channel open mapping is: accept-or-decline the channel open
(`ChannelOpenHandle`; dropping it auto-rejects — russh's default is (`ChannelOpenHandle`; dropping it auto-rejects, russh's default is
already fail-closed), pump/serve the channel through the seam, map the already fail-closed), pump/serve the channel through the seam, map the
request-level extras. request-level extras.
@@ -36,16 +36,16 @@ request-level extras.
| SSH channel type | Vanilla use | Feature (candidate) | Family seam | Notes | | SSH channel type | Vanilla use | Feature (candidate) | Family seam | Notes |
|---|---|---|---|---| |---|---|---|---|---|
| `session` → `pty_request`+`shell_request` | `ssh host` interactive shell | `session` | alktty producer: `NegotiateRequest` constructed locally → `drive_session` (or pre-allocated variant) | `pty_request` args → `TerminalParamsWire` (term/cols/rows/pixels; SSH modes → `modes` JSON reserved); shell case needs a cmd convention (OQ); PTY mode merges stderr into stdout (kernel PTY). **[POC-3 corrected]** the data-plane mapping is a *translation bridge*, not a byte pump: alktty's plane is chunk-framed both directions (stdin/stdout/stderr + CTRL_IN/CTRL_OUT chunks), so alkssh encodes SSH data→stdin chunks and decodes stdout→data, stderr→extended_data, exit chunk→exit-status/exit-signal. The exit chunk encodes pipe-path signal termination as the negative code `−N` (alktty ADR-004's convention — `pipe.rs::exit_code_from`); the PTY path reduces signal termination to a plain exit code (portable-pty's exit surface), so PTY-mode `exit-signal` fidelity is an alktty-backend question | | `session` → `pty_request`+`shell_request` | `ssh host` interactive shell | `session` | alktty producer: `NegotiateRequest` constructed locally → `drive_session` (or pre-allocated variant) | `pty_request` args → `TerminalParamsWire` (term/cols/rows/pixels; SSH modes → `modes` JSON reserved); shell case needs a cmd convention (OQ); PTY mode merges stderr into stdout (kernel PTY). **[POC-3 corrected]** the data-plane mapping is a *translation bridge*, not a byte pump: alktty's plane is chunk-framed both directions (stdin/stdout/stderr + CTRL_IN/CTRL_OUT chunks), so alkssh encodes SSH data→stdin chunks and decodes stdout→data, stderr→extended_data, exit chunk→exit-status/exit-signal. The exit chunk encodes pipe-path signal termination as the negative code `−N` (alktty ADR-004's convention, `pipe.rs::exit_code_from`); the PTY path reduces signal termination to a plain exit code (portable-pty's exit surface), so PTY-mode `exit-signal` fidelity is an alktty-backend question |
| `session` → `exec_request` (non-git command) | `ssh host "cmd"` | `session` | alktty pipe mode: `terminal: None`, separate stderr | stderr flows back as SSH `extended_data` code 1 — only valid without PTY | | `session` → `exec_request` (non-git command) | `ssh host "cmd"` | `session` | alktty pipe mode: `terminal: None`, separate stderr | stderr flows back as SSH `extended_data` code 1; only valid without PTY |
| `session` → `exec_request`/`subsystem_request` git | `git push/clone ssh://` | `git` | alkgit: parse command → `Service` + repo (same grammar as `preamble.rs`, without `\0host=`) → `serve_duplex_resolved(repo_id, identity, service, duplex, ...)` | Process-mode dialect is correct (SSH supplies service+repo out of band; server speaks first = advertisement). **[POC-3 corrected]** git over SSH is exec-only: a `subsystem` request carries the service *name* but no repo id, so even a git-service subsystem must decline — vanilla git always executes `git-upload-pack '/repo'`. POC-3: vanilla clone/push/second-push through the published seam + substrate composition, exit-status in both directions | | `session` → `exec_request`/`subsystem_request` git | `git push/clone ssh://` | `git` | alkgit: parse command → `Service` + repo (same grammar as `preamble.rs`, without `\0host=`) → `serve_duplex_resolved(repo_id, identity, service, duplex, ...)` | Process-mode dialect is correct (SSH supplies service+repo out of band; server speaks first = advertisement). **[POC-3 corrected]** git over SSH is exec-only: a `subsystem` request carries the service *name* but no repo id, so even a git-service subsystem must decline, as vanilla git always executes `git-upload-pack '/repo'`. POC-3: vanilla clone/push/second-push through the published seam + substrate composition, exit-status in both directions |
| `session` → SOCKS5-in-channel | (n/a — retired) | ~~`dynamic-forward`~~ **retired (ADR-003 §1a)** | was alksocks: `serve_socks5_over` | **Post-architecture retirement:** no consumer is ever the family-native client shape this row served. Vanilla `ssh -D` does SOCKS client-side and opens plain `direct-tcpip` (POC-3 correction 2 → the `tunnel` row); the family `-D` behavior is a *client-local* alksocks door over the alkssh client's `direct-tcpip` opens (assembly composition). POC-3 stage B remains the evidence that SOCKS-in-channel is feasible if a family client ever wants a remote SOCKS door (back via explicit ADR). **[ADR-003 amended]** | | `session` → SOCKS5-in-channel | (n/a, retired) | ~~`dynamic-forward`~~ **retired (ADR-003 §1a)** | was alksocks: `serve_socks5_over` | **Post-architecture retirement:** no consumer is ever the family-native client shape this row served. Vanilla `ssh -D` does SOCKS client-side and opens plain `direct-tcpip` (POC-3 correction 2 → the `tunnel` row); the family `-D` behavior is a *client-local* alksocks door over the alkssh client's `direct-tcpip` opens (assembly composition). POC-3 stage B remains the evidence that SOCKS-in-channel is feasible if a family client ever wants a remote SOCKS door (back via explicit ADR). **[ADR-003 amended]** |
| `direct-tcpip` | `ssh -L` | `tunnel` | alktunnels (two routes, below) | Target = `SubstrateAddr::HostPort`/`Path`; `Substrate` = Tcp/Unix | | `direct-tcpip` | `ssh -L` | `tunnel` | alktunnels (two routes, below) | Target = `SubstrateAddr::HostPort`/`Path`; `Substrate` = Tcp/Unix |
| `forwarded-tcpip` | `ssh -R` (data channel) | `tunnel` | alktunnels: `register_tunnel_forwarded_openable`'s `ForwardedHandler(params, BiStream)` shape — in the adapter, hand the russh channel duplex to the handler | listen flow via global-request rows below | | `forwarded-tcpip` | `ssh -R` (data channel) | `tunnel` | alktunnels: `register_tunnel_forwarded_openable`'s `ForwardedHandler(params, BiStream)` shape; in the adapter, hand the russh channel duplex to the handler | listen flow via global-request rows below |
| `direct-streamlocal` | unix-socket forward | `tunnel` | same `SubstrateAddr::Path` treatment | unix-only; decline on other platforms (compile-time feature + runtime decline) | | `direct-streamlocal` | unix-socket forward | `tunnel` | same `SubstrateAddr::Path` treatment | unix-only; decline on other platforms (compile-time feature + runtime decline) |
| `x11` | X forwarding | **never** (no feature) | — | decline, always; not even feature-gated initially (OQ: revisit years later if a use case arrives) | | `x11` | X forwarding | **never** (no feature) | none | decline, always; not even feature-gated initially (OQ: revisit years later if a use case arrives) |
| `sftp` (`subsystem_request("sftp")`) | sftp clients | `sftp` (future) | alkfs (deferred, family ADR-002) | The declinable placeholder — the feature name exists in the feature graph, decline until alkfs lands | | `sftp` (`subsystem_request("sftp")`) | sftp clients | `sftp` (future) | alkfs (deferred, family ADR-002) | The declinable placeholder: the feature name exists in the feature graph, decline until alkfs lands |
| `auth-agent` (`agent_request`) | ssh-agent forwarding | **never** | — | decline; family holds keys in alkvault — forwarding agents to remote sessions is the anti-pattern this posture rejects | | `auth-agent` (`agent_request`) | ssh-agent forwarding | **never** | none | decline; family holds keys in alkvault, since forwarding agents to remote sessions is the anti-pattern this posture rejects |
Notes: the rows deliberately split one channel type (`session`) across Notes: the rows deliberately split one channel type (`session`) across
three features, because vanilla SSH multiplexes everything through a three features, because vanilla SSH multiplexes everything through a
@@ -62,20 +62,20 @@ a shell, a pipe-mode command. The failure matrix this produces:
Dispatch is parse-then-route by command shape: parsing to a git Dispatch is parse-then-route by command shape: parsing to a git
service and repo requires `git`; otherwise the alktty pipe/pty path service and repo requires `git`; otherwise the alktty pipe/pty path
requires `session`. Parsing happens before dispatch and before any requires `session`. Parsing happens before dispatch and before any
resource allocation (a leaked channel alloc is a capability leak — resource allocation (a leaked channel alloc is a capability leak,
decline first). (Post-architecture: the former SOCKS-detect dispatch so decline first).. (Post-architecture: the former SOCKS-detect dispatch
arm is retired with the `dynamic-forward` feature — ADR-003 §1a / arm is retired with the `dynamic-forward` feature, per ADR-003 §1a /
ADR-006 amendment; request-less session channels decline.) Grammar/ ADR-006 amendment; request-less session channels decline.) Grammar/
precedence per ADR-006. precedence per ADR-006.
## Global-request rows (`tcpip_forward` / `streamlocal_forward`) — the `-R` listen flow ## Global-request rows (`tcpip_forward` / `streamlocal_forward`): the `-R` listen flow
`Handler::tcpip_forward(address, &mut port) -> bool` + per-open `Handler::tcpip_forward(address, &mut port) -> bool` + per-open
`channel_open_forwarded_tcpip`. Consumer-side alktunnels: `channel_open_forwarded_tcpip`. Consumer-side alktunnels:
`TunnelListener`, `open_reverse_channel`; the forwarded data channel `TunnelListener`, `open_reverse_channel`; the forwarded data channel
maps to the `ForwardedHandler` route above. Dispositions follow the maps to the `ForwardedHandler` route above. Dispositions follow the
`tunnel` feature (decline both listen and data when `tunnel` absent). `tunnel` feature (decline both listen and data when `tunnel` absent).
`port = 0` (server picks) interacts with the `&mut u32` out-param — `port = 0` (server picks) interacts with the `&mut u32` out-param;
spec detail deferred to phase 1. spec detail deferred to phase 1.
## Request-level mapping on a `session` channel ## Request-level mapping on a `session` channel
@@ -83,33 +83,33 @@ spec detail deferred to phase 1.
| SSH request | Family mapping | | SSH request | Family mapping |
|---|---| |---|---|
| `pty_request` | `NegotiateRequest.tty = TerminalParamsWire{term, cols, rows, pixel_w, pixel_h, modes}` | | `pty_request` | `NegotiateRequest.tty = TerminalParamsWire{term, cols, rows, pixel_w, pixel_h, modes}` |
| `shell_request` | `NegotiateRequest.cmd` per the backend's shell convention (OQ — probably `cmd` + `cwd`/`env` from assembly policy, not from the wire) | | `shell_request` | `NegotiateRequest.cmd` per the backend's shell convention (OQ: probably `cmd` + `cwd`/`env` from assembly policy, not from the wire) |
| `exec_request` | Parse command → git/service dispatch or alktty pipe `cmd`; unknown commands fail with `session.channel_failure` (parse-then-decline, no resource alloc) | | `exec_request` | Parse command → git/service dispatch or alktty pipe `cmd`; unknown commands fail with `session.channel_failure` (parse-then-decline, no resource alloc) |
| `subsystem_request` | `sftp` → decline until `sftp` feature serves alkfs; git subsystem names → same parse as exec; anything else → decline | | `subsystem_request` | `sftp` → decline until `sftp` feature serves alkfs; git subsystem names → same parse as exec; anything else → decline |
| `env_request` | **Decline by default.** Wire-supplied env is not family identity config; `TtyParams.env` comes from assembly. (OQ: allowlist shape, e.g. `LANG`, later.) | | `env_request` | **Decline by default.** Wire-supplied env is not family identity config; `TtyParams.env` comes from assembly. (OQ: allowlist shape, e.g. `LANG`, later.) |
| `window_change_request` | `TtyControlHandle::resize(cols, rows, pixel_w, pixel_h)` | | `window_change_request` | `TtyControlHandle::resize(cols, rows, pixel_w, pixel_h)` |
| `signal` | `TtyControlHandle::signal(name)` (alktty's `signal_from_name` accepts the HUP/INT/TERM/... names verbatim) | | `signal` | `TtyControlHandle::signal(name)` (alktty's `signal_from_name` accepts the HUP/INT/TERM/... names verbatim) |
| data / `extended_data(1)` | channel duplex in/out; stderr only meaningful in pipe mode (PTY merges) | | data / `extended_data(1)` | channel duplex in/out; stderr only meaningful in pipe mode (PTY merges) |
| `exit_status` | from `TtyHandle.exit_code` (`TtySession.wait()` semantics: i32; negative = signal-terminated on the pipe path — `pipe.rs::exit_code_from` maps Unix `ExitStatus::signal()` to `−sig`; the PTY path reduces signal termination to a plain exit code, see the exit_signal row) — alkssh sends the SSH `exit-status` request before channel close (POC-3: proven both directions via the cloned `Session::handle()` from the spawned serving task); `−1` (alktty's could-not-determine sentinel, alktty ADR-004 §4) → `exit-status(255)` | | `exit_status` | from `TtyHandle.exit_code` (`TtySession.wait()` semantics: i32; negative = signal-terminated on the pipe path; `pipe.rs::exit_code_from` maps Unix `ExitStatus::signal()` to `−sig`; the PTY path reduces signal termination to a plain exit code, see the exit_signal row), so alkssh sends the SSH `exit-status` request before channel close (POC-3: proven both directions via the cloned `Session::handle()` from the spawned serving task); `−1` (alktty's could-not-determine sentinel, alktty ADR-004 §4) → `exit-status(255)` |
| `exit_signal` | derived server-side from the exit chunk's negative code — `−N` → name via a Unix signal-number→name table; `core_dumped` never asserted (alktty discards `ExitStatus::core_dumped()`); no chunk change is needed or proposed (the encoding is alktty's documented contract, alknet ADR-055 lineage). POC-3 observed `−15` → `exit-signal("TERM")` live on the pipe path. PTY path: portable-pty 0.9's `ExitStatus` conversion yields `code = status.code().unwrap_or(1)` (**1**) for signal-terminated children, keeping the signal only in a separate humanized (`strsignal`) string field alktty's waiter discards — faithful `exit-signal` on the PTY row needs the alktty backend to conform to its own ADR convention (upstream ask: alktty/portable-pty trackers; the alkssh contract adapts automatically) | | `exit_signal` | derived server-side from the exit chunk's negative code, `−N` → name via a Unix signal-number→name table; `core_dumped` never asserted (alktty discards `ExitStatus::core_dumped()`); no chunk change is needed or proposed (the encoding is alktty's documented contract, alknet ADR-055 lineage). POC-3 observed `−15` → `exit-signal("TERM")` live on the pipe path. PTY path: portable-pty 0.9's `ExitStatus` conversion yields `code = status.code().unwrap_or(1)` (**1**) for signal-terminated children, keeping the signal only in a separate humanized (`strsignal`) string field alktty's waiter discards, so faithful `exit-signal` on the PTY row needs the alktty backend to conform to its own ADR convention (upstream ask: alktty/portable-pty trackers; the alkssh contract adapts automatically) |
## Data-plane / limits contract ## Data-plane / limits contract
- russh windows: `Config::window_size`, `maximum_packet_size`, - russh windows: `Config::window_size`, `maximum_packet_size`,
`channel_buffer_size` cap per-channel memory; `Limits` caps `channel_buffer_size` cap per-channel memory; `Limits` caps
rekey volume; `inactivity_timeout` the session wall clock — the rekey volume; `inactivity_timeout` the session wall clock, and the
family bounded-resources convention maps onto these knobs plus the family bounded-resources convention maps onto these knobs plus the
crate's own max-concurrent-channels (per session) and the per-length crate's own max-concurrent-channels (per session) and the per-length
parse limits on exec commands. parse limits on exec commands.
- alkcall bridge leg (when the family half is remote): channels-layer - alkcall bridge leg (when the family half is remote): channels-layer
reassembly cap 1 MiB/channel, chunk max 16 MiB, per-identity reassembly cap 1 MiB/channel, chunk max 16 MiB, per-identity
channel-count policy — an SSH channel bridged over `alk/channels` channel-count policy, so an SSH channel bridged over `alk/channels`
rides one reassembled byte stream per SSH channel; SSH's own rides one reassembled byte stream per SSH channel; SSH's own
multiplexing is why ADR-034 grants `alk/ssh` only stream_types multiplexing is why ADR-034 grants `alk/ssh` only stream_types
`[0, 1]` ("SSH multiplexes internally, including its own control"). `[0, 1]` ("SSH multiplexes internally, including its own control").
- Honest advertisement: `Config::methods` mirrors implemented auth; an - Honest advertisement: `Config::methods` mirrors implemented auth; an
unfeatured channel type is *declined at open*, never half-opened. unfeatured channel type is *declined at open*, never half-opened.
Vanilla clients see a clean failure — which is the contract. Vanilla clients see a clean failure, which is the contract.
## alktunnels absence note ## alktunnels absence note
@@ -126,7 +126,7 @@ decision:
`composed::direct_egress` is the family precedent for exactly this `composed::direct_egress` is the family precedent for exactly this
bridging.) bridging.)
2. **Registry-side bridge**: within an alkcall serving endpoint, 2. **Registry-side bridge**: within an alkcall serving endpoint,
`register_tunnel_direct_openable` with a `DialFn` — but that puts `register_tunnel_direct_openable` with a `DialFn`, but that puts
alkssh-shaped work in the serving endpoint's registry, which alkssh-shaped work in the serving endpoint's registry, which
inverts the door posture. Likely rejected on composability grounds; inverts the door posture. Likely rejected on composability grounds;
recorded here for the ADR to decide explicitly. recorded here for the ADR to decide explicitly.
@@ -136,17 +136,17 @@ decision:
Three alkcall ADR constraints collide productively: Three alkcall ADR constraints collide productively:
- ADR-004 reserves `alk/ssh` → `SshAdapter` (custom protocol). - ADR-004 reserves `alk/ssh` → `SshAdapter` (custom protocol).
- ADR-034's per-ALPN table: `alk/ssh` gets stream_types `[0, 1]` — - ADR-034's per-ALPN table: `alk/ssh` gets stream_types `[0, 1]`;
SSH multiplexes internally, the channels layer is byte-blind to it. SSH multiplexes internally, the channels layer is byte-blind to it.
- ADR-042/051: "The hub never runs a handler for `alk/tty`, `alk/ssh`, - ADR-042/051: "The hub never runs a handler for `alk/tty`, `alk/ssh`,
or `alk/tunnel`" — `alk/ssh` channels *relay payload-blind* between or `alk/tunnel`": `alk/ssh` channels *relay payload-blind* between
spokes; ACL fires at `channel/open`. spokes; ACL fires at `channel/open`.
ADR-009 additionally sketches a browser route: a WebTransport session ADR-009 additionally sketches a browser route: a WebTransport session
to `/alk/ssh` where each bidi stream is handed to `SshAdapter::handle` to `/alk/ssh` where each bidi stream is handed to `SshAdapter::handle`
as a `Connection`, with a WASM SSH parser speaking SSH over it. as a `Connection`, with a WASM SSH parser speaking SSH over it.
Reading: `alk/ssh` is plausibly *SSH-over-channels* — the raw vanilla- Reading: `alk/ssh` is plausibly *SSH-over-channels*: the raw vanilla-
SSH byte stream carried between alkssh instances (edge termination at SSH byte stream carried between alkssh instances (edge termination at
one end, mapped serving at the other), reused by the browser route. one end, mapped serving at the other), reused by the browser route.
Whether the edge-terminated topology (russh at the edge, family seams Whether the edge-terminated topology (russh at the edge, family seams
@@ -160,11 +160,11 @@ phase 1 ADR-001 (ALPN pin, ledger rule 1).
## Open questions seeded for phase 1 ## Open questions seeded for phase 1
- Session-channel dispatch precedence + the exec-command grammar - Session-channel dispatch precedence + the exec-command grammar
(git parse vs pipe cmd) — parse-then-fail-closed contract. (git parse vs pipe cmd), a parse-then-fail-closed contract.
- Shell command convention for `shell_request` (assembly policy vs - Shell command convention for `shell_request` (assembly policy vs
wire-supplied; `wd`/`env` provenance). wire-supplied; `wd`/`env` provenance).
- `env_request` policy (decline-only vs allowlist). - `env_request` policy (decline-only vs allowlist).
- OQ: OpenSSH certificate auth (`auth_openssh_certificate`) — decline - OQ: OpenSSH certificate auth (`auth_openssh_certificate`): decline
until a use case. until a use case.
- OQ: username disposition (ignored entirely vs a routing hint). - OQ: username disposition (ignored entirely vs a routing hint).
- Feature-graph names + the `session`-vs-`git`-vs-`dynamic-forward` - Feature-graph names + the `session`-vs-`git`-vs-`dynamic-forward`
+31 -31
View File
@@ -1,7 +1,7 @@
# POC-1 findings — auth bridge + fail-closed declines end to end # POC-1 findings: auth bridge + fail-closed declines end to end
Status: Complete (phase 0). POC source: `/workspace/alkssh-poc1` Status: Complete (phase 0). POC source: `/workspace/alkssh-poc1`
(standalone crate, disposable — never merges). All hypotheses in (standalone crate, disposable, never merges). All hypotheses in
`pocs.md` §POC-1 were confirmed; the doc below records the evidence, `pocs.md` §POC-1 were confirmed; the doc below records the evidence,
one correction to the phase 0 research docs, two API-shape facts, and one correction to the phase 0 research docs, two API-shape facts, and
the OQ seeds this POC produced. the OQ seeds this POC produced.
@@ -11,8 +11,8 @@ the OQ seeds this POC produced.
- russh **0.64.1** from crates.io (the current published release; - russh **0.64.1** from crates.io (the current published release;
crates.io `newest_version` re-verified during the POC), default crates.io `newest_version` re-verified during the POC), default
features off, crypto backend `ring`. Clean build under edition 2024, features off, crypto backend `ring`. Clean build under edition 2024,
rustc 1.99.0. The `ring` backend built with no external provisioning rustc 1.99.0. The `ring` backend built with no external provisioning,
— relevant data point for the pin ADR's crypto-backend question. a relevant data point for the pin ADR's crypto-backend question.
- Dependency tree confirms the pin considerations in `russh.md`: - Dependency tree confirms the pin considerations in `russh.md`:
`ssh-key 0.7.0-rc.11` exact-pinned and re-exported via `ssh-key 0.7.0-rc.11` exact-pinned and re-exported via
`russh::keys::ssh_key`; `rand_core 0.10` in-tree. Key generation in `russh::keys::ssh_key`; `rand_core 0.10` in-tree. Key generation in
@@ -22,20 +22,20 @@ the OQ seeds this POC produced.
- Client side: russh client (`client::Handler`, `client::connect`) as - Client side: russh client (`client::Handler`, `client::connect`) as
the primary programmatic client, plus a stub `IdentityProvider` the primary programmatic client, plus a stub `IdentityProvider`
registered via fingerprints taken **from `ssh-keygen -lf` output** registered via fingerprints taken **from `ssh-keygen -lf` output**
(independent OpenSSH implementation) — making the parity check a (independent OpenSSH implementation), making the parity check a
real cross-implementation check, not a same-code self-check. real cross-implementation check, not a same-code self-check.
- Vanilla client: OpenSSH 9.6p1 (`ssh`, `ssh -L`), isolated - Vanilla client: OpenSSH 9.6p1 (`ssh`, `ssh -L`), isolated
`UserKnownHostsFile`, `BatchMode`, `IdentitiesOnly`. `UserKnownHostsFile`, `BatchMode`, `IdentitiesOnly`.
## Validated: all checks pass (31/31) ## Validated: all checks pass (31/31)
### 1. Fingerprint-format parity — confirmed, format is exactly the registered string ### 1. Fingerprint-format parity confirmed: format is exactly the registered string
`public_key.fingerprint(HashAlg::Sha256).to_string()` (via the `public_key.fingerprint(HashAlg::Sha256).to_string()` (via the
`ssh-key` re-export) yields `SHA256:<unpadded base64, standard `ssh-key` re-export) yields `SHA256:<unpadded base64, standard
alphabet>` — byte-identical to `ssh-keygen -lf` on the same public key. (An alphabet>`, byte-identical to `ssh-keygen -lf` on the same public key. (An
earlier phrasing of this section called the encoding "url-safe"; the earlier phrasing of this section called the encoding "url-safe"; the
observed strings and OpenSSH's own format use the standard alphabet — observed strings and OpenSSH's own format use the standard alphabet,
unpadded standard base64 is the invariant.) The unpadded standard base64 is the invariant.) The
`resolve_from_fingerprint` string compare works with values that were `resolve_from_fingerprint` string compare works with values that were
registered from OpenSSH tooling output. Parity was additionally registered from OpenSSH tooling output. Parity was additionally
@@ -47,7 +47,7 @@ self-describing; the assembly layer can register `SHA256:` strings
taken from any standard issuer (ssh-keygen, alkcall-side computation taken from any standard issuer (ssh-keygen, alkcall-side computation
via the same re-export) and the server-side compare holds. via the same re-export) and the server-side compare holds.
### 2. Auth bridge end to end — confirmed as designed ### 2. Auth bridge end to end (confirmed as designed)
- `auth_publickey` fires **after** russh's own signature verification - `auth_publickey` fires **after** russh's own signature verification
(documented behavior observed: the vanilla key was accepted without (documented behavior observed: the vanilla key was accepted without
@@ -59,14 +59,14 @@ via the same re-export) and the server-side compare holds.
- `auth_publickey_offered` accept-and-let-decide works with OpenSSH's - `auth_publickey_offered` accept-and-let-decide works with OpenSSH's
probe-then-sign flow: the hook fires (key seen, ownership unverified) probe-then-sign flow: the hook fires (key seen, ownership unverified)
before the signed attempt, and **does not count toward before the signed attempt, and **does not count toward
`max_auth_attempts`** — consistent with russh's own doc warning `max_auth_attempts`**, consistent with russh's own doc warning
about keeping the count honest. about keeping the count honest.
- Username: ignored entirely for identity (keys resolve independently - Username: ignored entirely for identity (keys resolve independently
of the username string sent by the client) — supports the of the username string sent by the client), which supports the
"ignore username entirely" OQ direction; `ssh anything@host` "ignore username entirely" OQ direction; `ssh anything@host`
compatibility is unaffected. compatibility is unaffected.
### 3. Decline matrix — every method declined and proven ### 3. Decline matrix: every method declined and proven
- `auth_none` / `auth_password` / `auth_keyboard_interactive` all - `auth_none` / `auth_password` / `auth_keyboard_interactive` all
observed declining (russh client forced each; vanilla client with observed declining (russh client forced each; vanilla client with
@@ -84,10 +84,10 @@ Only `auth_publickey_offered` defaults to accept. The research assumed
older-russh accept-defaults. The fail-closed discipline at 0.64.1 is older-russh accept-defaults. The fail-closed discipline at 0.64.1 is
therefore largely *inherited*; the family contract still requires therefore largely *inherited*; the family contract still requires
implementing every hook explicitly (a future russh bump could change implementing every hook explicitly (a future russh bump could change
defaults — the pin is a one-way door) and POC-1 proves the explicit defaults; the pin is a one-way door) and POC-1 proves the explicit
implementations end to end. implementations end to end.
### 4. Bounded-resource auth behavior — `max_auth_attempts` semantics ### 4. Bounded-resource auth behavior: `max_auth_attempts` semantics
The **initial `none` probe counts toward `max_auth_attempts`**: with a The **initial `none` probe counts toward `max_auth_attempts`**: with a
limit of 3, a BatchMode vanilla client got the `none` probe plus two limit of 3, a BatchMode vanilla client got the `none` probe plus two
@@ -101,7 +101,7 @@ signed declined attempts and was disconnected with
mind (e.g. limit 10 default is comfortable; a small limit eats one mind (e.g. limit 10 default is comfortable; a small limit eats one
attempt on every connection). attempt on every connection).
### 5. Fail-closed channel declines observable by vanilla ssh — confirmed ### 5. Fail-closed channel declines observable by vanilla ssh (confirmed)
- `ChannelOpenHandle` **drop = auto-reject** sends - `ChannelOpenHandle` **drop = auto-reject** sends
`AdministrativelyProhibited` (verified in russh source `AdministrativelyProhibited` (verified in russh source
@@ -109,16 +109,16 @@ signed declined attempts and was disconnected with
explicit reject call needed anywhere: unfeatured channel types are explicit reject call needed anywhere: unfeatured channel types are
handled by simply not accepting the handle. handled by simply not accepting the handle.
- Vanilla `ssh host` after successful auth: `channel 0: open failed: - Vanilla `ssh host` after successful auth: `channel 0: open failed:
administratively prohibited: Administratively prohibited`, exit 255 — administratively prohibited: Administratively prohibited`, exit 255:
clean diagnosis, no hang, and the **connection-level auth remains clean diagnosis, no hang, and the **connection-level auth remains
intact** (the decline is channel-scoped, not a disconnect). intact** (the decline is channel-scoped, not a disconnect).
- Vanilla `ssh -L 127.0.0.1:P:127.0.0.1:P -N`: the local listener - Vanilla `ssh -L 127.0.0.1:P:127.0.0.1:P -N`: the local listener
establishes; dialing through it opens a `direct-tcpip` channel that establishes; dialing through it opens a `direct-tcpip` channel that
is refused — the local dial gets TCP-level reset ("Connection reset is refused, so the local dial gets TCP-level reset ("Connection reset
by peer"), no data ever crosses. The server-side log shows the drop by peer"), no data ever crosses. The server-side log shows the drop
with the forward target. with the forward target.
- Session remains usable after declines (further channel opens - Session remains usable after declines (further channel opens
correctly declined too — no state poisoning). correctly declined too, with no state poisoning).
### 6. russh client API shapes on rejects (new facts for the mapping docs) ### 6. russh client API shapes on rejects (new facts for the mapping docs)
@@ -133,7 +133,7 @@ signed declined attempts and was disconnected with
auto-reject covers x11, direct-tcpip, forwarded-tcpip, auto-reject covers x11, direct-tcpip, forwarded-tcpip,
direct-streamlocal uniformly. direct-streamlocal uniformly.
- `PublicKeyOrCertificate::PublicKey` is a struct variant - `PublicKeyOrCertificate::PublicKey` is a struct variant
(`{ key, hash_alg }`) — small API detail when reading host keys in (`{ key, hash_alg }`), a small API detail when reading host keys in
`check_server_key`. `check_server_key`.
- **Client-side plumb caveat (recorded for POC-2/3):** `Channel::wait()` - **Client-side plumb caveat (recorded for POC-2/3):** `Channel::wait()`
on a just-opened client channel *before* `into_stream()` breaks the on a just-opened client channel *before* `into_stream()` breaks the
@@ -142,7 +142,7 @@ signed declined attempts and was disconnected with
internally; do not consume the channel queue before converting to a internally; do not consume the channel queue before converting to a
stream. With that caveat respected, the end-to-end echo works. stream. With that caveat respected, the end-to-end echo works.
### 7. Channel ↔ AsyncRead/AsyncWrite plumbing — confirmed ### 7. Channel ↔ AsyncRead/AsyncWrite plumbing (confirmed)
`into_stream()` works on both sides: server accepts a session channel, `into_stream()` works on both sides: server accepts a session channel,
`channel.into_stream()`, reads/writes in a plain `channel.into_stream()`, reads/writes in a plain
@@ -154,17 +154,17 @@ POC-2 (dial lifecycle) and POC-3 (mapping bake-off) will reuse.
## Hypothesis verdicts (pocs.md §POC-1) ## Hypothesis verdicts (pocs.md §POC-1)
- "`auth_publickey` → fingerprint string parity → resolve → accept; - "`auth_publickey` → fingerprint string parity → resolve → accept;
unknown key → clean decline" — **confirmed**. unknown key → clean decline" is **confirmed**.
- "`auth_publickey_offered` accept-and-let-decide with OpenSSH - "`auth_publickey_offered` accept-and-let-decide with OpenSSH
probe-then-sign" — **confirmed** (and confirmed it doesn't inflate probe-then-sign" is **confirmed** (and confirmed it doesn't inflate
the attempt counter). the attempt counter).
- "Default auth impls are accept — every default must be overridden" - "Default auth impls are accept, so every default must be overridden"
— **corrected**: at 0.64.1 only the offered hook defaults to accept. is **corrected**: at 0.64.1 only the offered hook defaults to accept.
Explicit implementations still required by the family contract and Explicit implementations still required by the family contract and
proven here. proven here.
- "`ChannelOpenHandle` drop = auto-reject; declines observable by - "`ChannelOpenHandle` drop = auto-reject; declines observable by
vanilla ssh; clean diagnostics" — **confirmed**. vanilla ssh; clean diagnostics" is **confirmed**.
- "`Channel` ↔ AsyncRead/AsyncWrite (`into_stream`) works" — - "`Channel` ↔ AsyncRead/AsyncWrite (`into_stream`) works" is
**confirmed**, with the wait-before-into_stream caveat. **confirmed**, with the wait-before-into_stream caveat.
## OQ seeds carried forward ## OQ seeds carried forward
@@ -174,12 +174,12 @@ POC-2 (dial lifecycle) and POC-3 (mapping bake-off) will reuse.
whether usernames are ever logged/matched (vanilla clients always whether usernames are ever logged/matched (vanilla clients always
send one). send one).
2. `max_auth_attempts` sizing must account for the counting `none` 2. `max_auth_attempts` sizing must account for the counting `none`
probe (see §4) — a config default note for the ADR. probe (see §4), a config default note for the ADR.
3. Vanilla clients probe with `none` first; a future russh bump that 3. Vanilla clients probe with `none` first; a future russh bump that
changes reject-defaults would be a silent-auth posture change — changes reject-defaults would be a silent-auth posture change, which
reinforces recording the russh pin as a deliberate one-way door. reinforces recording the russh pin as a deliberate one-way door.
4. `auth_openssh_certificate` override-decline exists but was not 4. `auth_openssh_certificate` override-decline exists but was not
exercised with a real certificate (no CA in the POC) — the exercised with a real certificate (no CA in the POC), so the
"decline initially" row stands unfalsified. "decline initially" row stands unfalsified.
## Reference run ## Reference run
@@ -187,13 +187,13 @@ POC-2 (dial lifecycle) and POC-3 (mapping bake-off) will reuse.
Full output of a clean run at `pocs.md` commit time: Full output of a clean run at `pocs.md` commit time:
31 checks, 6 groups (fingerprint parity 2, russh client auth 9, 31 checks, 6 groups (fingerprint parity 2, russh client auth 9,
channel declines 3, vanilla OpenSSH 11, max_auth_attempts 1, echo channel declines 3, vanilla OpenSSH 11, max_auth_attempts 1, echo
plumbing 5) — all PASS. Vanilla diagnostic strings quoted above are plumbing 5), all PASS. Vanilla diagnostic strings quoted above are
verbatim from that run. verbatim from that run.
## References ## References
- `pocs.md` §POC-1 (plan), `auth-bridge.md` (design), `russh.md` (API - `pocs.md` §POC-1 (plan), `auth-bridge.md` (design), `russh.md` (API
inventory — updated with the 0.64.1 default-impl correction) inventory, updated with the 0.64.1 default-impl correction)
- POC source: `/workspace/alkssh-poc1` (single `src/main.rs` + stub - POC source: `/workspace/alkssh-poc1` (single `src/main.rs` + stub
alkcall-shaped `IdentityProvider` in `authbridge` module) alkcall-shaped `IdentityProvider` in `authbridge` module)
- russh 0.64.1 (crates.io, ring backend), ssh-key 0.7.0-rc.11 - russh 0.64.1 (crates.io, ring backend), ssh-key 0.7.0-rc.11
+55 -55
View File
@@ -1,7 +1,7 @@
# POC-2 findings — client-side at-rest key handling (vault-backed storage) # POC-2 findings: client-side at-rest key handling (vault-backed storage)
Status: Complete (phase 0). POC source: `/workspace/alkssh-poc2` Status: Complete (phase 0). POC source: `/workspace/alkssh-poc2`
(standalone crate, disposable — never merges). All hypotheses in (standalone crate, disposable, never merges). All hypotheses in
`pocs.md` §POC-2 were confirmed; the doc below records the evidence, `pocs.md` §POC-2 were confirmed; the doc below records the evidence,
the inner-format comparison, the Signer-seam dismissal (now with the inner-format comparison, the Signer-seam dismissal (now with
runtime + source evidence instead of the earlier reasoning-only runtime + source evidence instead of the earlier reasoning-only
@@ -13,16 +13,16 @@ and the OQ seeds this POC produced.
- russh **0.64.1** (crates.io, ring backend) + **alkvault 0.1.0** - russh **0.64.1** (crates.io, ring backend) + **alkvault 0.1.0**
(crates.io; its pinned surface: `EncryptedData`, `VaultServiceHandle`, (crates.io; its pinned surface: `EncryptedData`, `VaultServiceHandle`,
`CURRENT_KEY_VERSION`), ssh-key **0.7.0-rc.11** exact-pinned and `CURRENT_KEY_VERSION`), ssh-key **0.7.0-rc.11** exact-pinned and
re-exported through `russh::keys::ssh_key` (identical tree to POC-1's re-exported through `russh::keys::ssh_key` (identical tree to POC-1's,
— the two POCs exercised the same dependency graph). since the two POCs exercised the same dependency graph).
- Server side: POC-1's loopback shape verbatim (fingerprint registry - Server side: POC-1's loopback shape verbatim (fingerprint registry
fed from `ssh-keygen -lf` output, stub `IdentityProvider`, fed from `ssh-keygen -lf` output, stub `IdentityProvider`,
`into_stream` echo server, no `wait()` before `into_stream`). `into_stream` echo server, no `wait()` before `into_stream`).
- The adapter-shaped seam under test (`vaultkey` module in the POC): - The adapter-shaped seam under test (`vaultkey` module in the POC):
- `KeySource { blob: Option<EncryptedData> }` — the assembly-layer - `KeySource { blob: Option<EncryptedData> }`, the assembly-layer
storage stand-in (`None` = absent material); storage stand-in (`None` = absent material);
- `VaultKeyStore::load(&KeySource) -> Result<DialKey, LoadError>` — - `VaultKeyStore::load(&KeySource) -> Result<DialKey, LoadError>`, the
decrypt-at-dial: `vault.decrypt(blob)` → plaintext wrapped in decrypt-at-dial path: `vault.decrypt(blob)` → plaintext wrapped in
`Zeroizing<String>` at the seam → one-time `Zeroizing<String>` at the seam → one-time
`russh::keys::decode_secret_key(&plain, None)` → fingerprint `russh::keys::decode_secret_key(&plain, None)` → fingerprint
computed in the same step. Every error shape maps to a distinct computed in the same step. Every error shape maps to a distinct
@@ -33,7 +33,7 @@ and the OQ seeds this POC produced.
## Validated: all checks pass (39/39) ## Validated: all checks pass (39/39)
### 1. At-rest shape — OpenSSH-encoded key inside alkvault `EncryptedData` confirmed; the `&str`-only API constraint is decisive ### 1. At-rest shape: OpenSSH-encoded key inside alkvault `EncryptedData` confirmed; the `&str`-only API constraint is decisive
alkvault's `encrypt`/`decrypt` surface is **`&str` in, `String` out** alkvault's `encrypt`/`decrypt` surface is **`&str` in, `String` out**
(`VaultServiceHandle::encrypt(plaintext: &str, key_version: u32) -> (`VaultServiceHandle::encrypt(plaintext: &str, key_version: u32) ->
@@ -44,9 +44,9 @@ dialed:
| inner format | plaintext | blob ciphertext | key reconstruction at use | verdict | | inner format | plaintext | blob ciphertext | key reconstruction at use | verdict |
|---|---|---|---|---| |---|---|---|---|---|
| **OpenSSH PEM** (unencrypted) | 387 B | 540 B | `decode_secret_key(plain, None)` — one call | **winner** | | **OpenSSH PEM** (unencrypted) | 387 B | 540 B | `decode_secret_key(plain, None)`, one call | **winner** |
| raw ed25519 seed (base64) | 32 B | 80 B | `Ed25519Keypair::from_seed(&seed) -> PrivateKey` (`From` exists) | works, smallest blob; drops comment/public metadata (pub is re-derived from seed — fine) | | raw ed25519 seed (base64) | 32 B | 80 B | `Ed25519Keypair::from_seed(&seed) -> PrivateKey` (`From` exists) | works, smallest blob; drops comment/public metadata (pub is re-derived from seed, which is fine) |
| ssh-key self-encrypted PEM (passphrase, bcrypt KDF) | 449 B | 616 B | two decryptions: vault, then `decode_secret_key(pem, Some(pass))` | works but pointless — the passphrase is *itself* a stored secret, so the model just moves the same problem one level down; double-wrapping buys nothing | | ssh-key self-encrypted PEM (passphrase, bcrypt KDF) | 449 B | 616 B | two decryptions: vault, then `decode_secret_key(pem, Some(pass))` | works but pointless: the passphrase is *itself* a stored secret, so the model just moves the same problem one level down; double-wrapping buys nothing |
| PKCS#8 PEM (unencrypted) | 246 B | 252 B | `encode_pkcs8_pem` (russh) at rest; `decode_secret_key(plain, None)` at use | works; loses the native comment/model round-trip; adds nothing over OpenSSH PEM | | PKCS#8 PEM (unencrypted) | 246 B | 252 B | `encode_pkcs8_pem` (russh) at rest; `decode_secret_key(plain, None)` at use | works; loses the native comment/model round-trip; adds nothing over OpenSSH PEM |
(All four rows produced the correct fingerprint through the full (All four rows produced the correct fingerprint through the full
@@ -59,7 +59,7 @@ The **OpenSSH PEM inner wins** on honest grounds: ASCII natively (no
encode layer), self-describing (algorithm + public key + comment encode layer), self-describing (algorithm + public key + comment
embedded), vanilla-OpenSSH-tool-compatible at every boundary, and embedded), vanilla-OpenSSH-tool-compatible at every boundary, and
exactly one decode call from russh. The seed variant is a legitimate exactly one decode call from russh. The seed variant is a legitimate
size optimization the ADR can note (80 B vs 540 B blobs) — it couples size optimization the ADR can note (80 B vs 540 B blobs), but it couples
alkssh's storage to a fixed key shape though, whereas "a PEM file's alkssh's storage to a fixed key shape though, whereas "a PEM file's
bytes" works for every algorithm ssh-key can decode. This mirrors the bytes" works for every algorithm ssh-key can decode. This mirrors the
alkhttp ADR-014 analogy exactly as hypothesized in `pocs.md`. alkhttp ADR-014 analogy exactly as hypothesized in `pocs.md`.
@@ -69,17 +69,17 @@ At-rest hygiene, verified end to end:
- `EncryptedData { key_version: 2, salt, iv, data }` serializes to - `EncryptedData { key_version: 2, salt, iv, data }` serializes to
stable JSON (serde), roundtrips, and decrypts to the byte-identical stable JSON (serde), roundtrips, and decrypts to the byte-identical
original PEM. The salt field is 32 B (44 b64 chars), the IV 12 B original PEM. The salt field is 32 B (44 b64 chars), the IV 12 B
(16 b64 chars) — pure wire-format confirmation against the type's (16 b64 chars), pure wire-format confirmation against the type's
documented shape. documented shape.
- AES-256-GCM opacity: the ciphertext's `data` field contains no - AES-256-GCM opacity: the ciphertext's `data` field contains no
recognizable substrings of the key (checked `OPENSSH`, `PRIVATE`, recognizable substrings of the key (checked `OPENSSH`, `PRIVATE`,
`ssh-ed25519`, and a 30-byte interior slice of the PEM — none found `ssh-ed25519`, and a 30-byte interior slice of the PEM; none found
in ciphertext or the serialized JSON). in ciphertext or the serialized JSON).
- Fresh IV + fresh salt per encrypt: re-encrypting the identical - Fresh IV + fresh salt per encrypt: re-encrypting the identical
plaintext produces entirely different blobs (no determinism trap for plaintext produces entirely different blobs (no determinism trap for
naive dedup code to stumble into). naive dedup code to stumble into).
### 2. Dial lifecycle — decrypt-at-dial, zeroization bounds, lock/unlock mid-session ### 2. Dial lifecycle: decrypt-at-dial, zeroization bounds, lock/unlock mid-session
- The full dial through the adapter works: `load` → - The full dial through the adapter works: `load` →
`authenticate_publickey` → `Success`, the server's `authenticate_publickey` → `Success`, the server's
@@ -95,10 +95,10 @@ At-rest hygiene, verified end to end:
- Re-`unlock` with the same mnemonic → decrypt works again, same - Re-`unlock` with the same mnemonic → decrypt works again, same
fingerprint. Lock/unlock cycles are side-effect-free on the key. fingerprint. Lock/unlock cycles are side-effect-free on the key.
- **Zeroization bounds** (source-verified in ssh-key 0.7.0-rc.11): - **Zeroization bounds** (source-verified in ssh-key 0.7.0-rc.11):
- `Ed25519PrivateKey` zeroizes on `Drop` (`private/ed25519.rs` — - `Ed25519PrivateKey` zeroizes on `Drop` (`private/ed25519.rs`,
`impl Drop { self.0.zeroize() }`), so the decoded key's secret `impl Drop { self.0.zeroize() }`), so the decoded key's secret
bytes are cleaned up by ssh-key itself when the `PrivateKey` (or bytes are cleaned up by ssh-key itself when the `PrivateKey` (or
clones of it — the type is `Clone` with zeroizing storage) drops; clones of it, the type is `Clone` with zeroizing storage) drops;
- alkvault zeroizes its own key material (`EncryptionKey` - alkvault zeroizes its own key material (`EncryptionKey`
`#[zeroize(drop)]`, mnemonic phrase via `Zeroizing`, `lock()` `#[zeroize(drop)]`, mnemonic phrase via `Zeroizing`, `lock()`
purges seed + cache); purges seed + cache);
@@ -107,32 +107,32 @@ At-rest hygiene, verified end to end:
from the vault's return until the adapter takes ownership. The from the vault's return until the adapter takes ownership. The
adapter shape proven here wraps it in `Zeroizing<String>` at the adapter shape proven here wraps it in `Zeroizing<String>` at the
seam (owning the move), so the adapter-bound bytes zeroize on seam (owning the move), so the adapter-bound bytes zeroize on
drop — but the pre-wrap window is the vault's API shape to close. drop, but the pre-wrap window is the vault's API shape to close.
→ OQ seed below. → OQ seed below.
### 3. Failure matrix — every failure mode observed; all fail closed ### 3. Failure matrix: every failure mode observed, all fail closed
| disturbed condition | observed | adapter mapping | | disturbed condition | observed | adapter mapping |
|---|---|---| |---|---|---|
| vault locked | `VaultServiceError::VaultLocked` | `LoadError::Locked` — no dial | | vault locked | `VaultServiceError::VaultLocked` | `LoadError::Locked`; no dial |
| blob absent at source (assembly layer has no record) | no vault call needed | `LoadError::Absent` — no dial | | blob absent at source (assembly layer has no record) | no vault call needed | `LoadError::Absent`; no dial |
| wrong mnemonic (unrelated seed) | `VaultServiceError::Encryption("decryption error: aead::Error")` — a *decryption* failure, distinguishable from `Locked` | no dial | | wrong mnemonic (unrelated seed) | `VaultServiceError::Encryption("decryption error: aead::Error")`, a *decryption* failure, distinguishable from `Locked` | no dial |
| tampered ciphertext (one flipped byte) | GCM integrity failure (same decrypt-error family) | no dial | | tampered ciphertext (one flipped byte) | GCM integrity failure (same decrypt-error family) | no dial |
| `key_version = 0` (reserved/legacy) | `VaultServiceError::InvalidPath` — the vault refuses before touching crypto | no dial | | `key_version = 0` (reserved/legacy) | `VaultServiceError::InvalidPath`; the vault refuses before touching crypto | no dial |
| `key_version = 7` (future, undecodable) | derivation-path error | no dial | | `key_version = 7` (future, undecodable) | derivation-path error | no dial |
| healthy rotation v2→v3 | `rotate()` → new blob stamped `key_version: 3`, decrypt yields the identical PEM, decoded fingerprint identical | dial succeeds | | healthy rotation v2→v3 | `rotate()` → new blob stamped `key_version: 3`, decrypt yields the identical PEM, decoded fingerprint identical | dial succeeds |
The wrong-mnemonic/tampered cases land in The wrong-mnemonic/tampered cases land in
`VaultServiceError::Encryption` *as strings* — the error taxonomy at `VaultServiceError::Encryption` *as strings*, and the error taxonomy at
the adapter is ours to shape (distinct variants), but the root causes the adapter is ours to shape (distinct variants), but the root causes
are distinguishable today via the variant + message. The `InvalidPath` behavior for version < 2 is deliberate (v1 is the TS_PBKDF2 legacy the are distinguishable today via the variant + message. The `InvalidPath` behavior for version < 2 is deliberate (v1 is the TS_PBKDF2 legacy the
vault cannot decrypt; rotation re-encryption is the migration path, vault cannot decrypt; rotation re-encryption is the migration path,
per alkvault's own docs) — consistent with alkvault's ADR-021 per alkvault's own docs), consistent with alkvault's ADR-021
version-indexed rotation model. version-indexed rotation model.
### 4. Signer-seam dismissal — confirmed, now with runtime and source evidence ### 4. Signer-seam dismissal: confirmed, now with runtime and source evidence
`pocs.md` expected the answer "no — recorded so the ADR can dismiss it `pocs.md` expected the answer "no, recorded so the ADR can dismiss it
with evidence". Evidence assembled: with evidence". Evidence assembled:
- **The trait exists but has no provided implementation**: russh - **The trait exists but has no provided implementation**: russh
@@ -141,50 +141,50 @@ with evidence". Evidence assembled:
hash_alg, to_sign)`), and `Handle::authenticate_publickey_with(user, hash_alg, to_sign)`), and `Handle::authenticate_publickey_with(user,
ssh_key::PublicKey, hash_alg, &mut S)` accepts it. The doc comment ssh_key::PublicKey, hash_alg, &mut S)` accepts it. The doc comment
claims "Currently, this crate only provides an implementation for an claims "Currently, this crate only provides an implementation for an
SSH agent" — **that is stale**: there is no `impl Signer` for SSH agent", but **that is stale**: there is no `impl Signer` for
`AgentClient` (or anything else) anywhere in the 0.64.1 tree. A `AgentClient` (or anything else) anywhere in the 0.64.1 tree. A
custom signer must be built by hand. custom signer must be built by hand.
- **The wire contract is non-obvious and hand-rolled**: the flow is - **The wire contract is non-obvious and hand-rolled**: the flow is
probe (`has_sig=0`, `write_auth_request`) → server `USERAUTH_PK_OK` probe (`has_sig=0`, `write_auth_request`) → server `USERAUTH_PK_OK`
→ client builds `session_id || userauth-prefix` (`client_make_to_sign`) → client builds `session_id || userauth-prefix` (`client_make_to_sign`)
→ `Reply::SignRequest` carries the full buffer → the signer returns → `Reply::SignRequest` carries the full buffer → the signer returns
**`to_sign || string{signature}`** — the original buffer with the **`to_sign || string{signature}`** (the original buffer with the
signature length-prefixed as an SSH string — and the client splices signature length-prefixed as an SSH string) and the client splices
`buffer[i0..]` as the packet payload. (First attempt appended the raw `buffer[i0..]` as the packet payload. (First attempt appended the raw
signature without the outer length prefix: the server silently signature without the outer length prefix: the server silently
misparsed and declined — `USERAUTH_FAILURE` — *without the misparsed and declined (`USERAUTH_FAILURE`), *without the
`auth_publickey` hook ever firing*. Signature-verification failures `auth_publickey` hook ever firing*. Signature-verification failures
happen inside russh before any handler hook, which is also a clean happen inside russh before any handler hook, which is also a clean
fail-closed property worth noting for the mapping docs.) fail-closed property worth noting for the mapping docs.)
- **Runtime proof that the seam works anyway**: `VaultSigner` - **Runtime proof that the seam works anyway**: `VaultSigner`
(decrypt-per-sign against the vault) authenticated successfully (decrypt-per-sign against the vault) authenticated successfully
end-to-end via `authenticate_publickey_with` — the splice convention end-to-end via `authenticate_publickey_with`: the splice convention
replicated, `PrivateKey: signature::Signer::try_sign` + replicated, `PrivateKey: signature::Signer::try_sign` +
`.encoded()` (i.e. `sig` → SSH wire blob) then length-prefixed into `.encoded()` (i.e. `sig` → SSH wire blob) then length-prefixed into
the returned buffer. the returned buffer.
- **Locked-vault fail-closed on the Signer path, too**: with the vault - **Locked-vault fail-closed on the Signer path, too**: with the vault
locked, the sign-time decrypt fails and the auth call itself errors locked, the sign-time decrypt fails and the auth call itself errors
(`Err` from `authenticate_publickey_with`) — no half-auth, no (`Err` from `authenticate_publickey_with`): no half-auth, no
fallback. fallback.
- **Verdict: dismissed for the vault path.** The vault is in-process: - **Verdict: dismissed for the vault path.** The vault is in-process:
decrypted key bytes necessarily exist client-side at *either* dial decrypted key bytes necessarily exist client-side at *either* dial
time or sign time — the Signer seam buys no custody improvement, and time or sign time; the Signer seam buys no custody improvement, and
decrypt-per-sign merely multiplies GCM+parse work per signature while decrypt-per-sign merely multiplies GCM+parse work per signature while
keeping *more* plaintext copies alive (per-signature) rather than keeping *more* plaintext copies alive (per-signature) rather than
fewer. The seam's genuine home is *external* signer processes fewer. The seam's genuine home is *external* signer processes
(ssh-agent), which the family treats as out of scope. The ADR can (ssh-agent), which the family treats as out of scope. The ADR can
record: one-time `decode_secret_key` at dial; `Signer` not taken. record: one-time `decode_secret_key` at dial; `Signer` not taken.
### 5. Host keys ride the same model — server bootstrap + vanilla compatibility ### 5. Host keys ride the same model: server bootstrap + vanilla compatibility
- The server's host key was generated in-POC, vault-encrypted, and - The server's host key was generated in-POC, vault-encrypted, and
decrypted-at-construction into `Config::keys` through the same decrypted-at-construction into `Config::keys` through the same
`VaultKeyStore::load` the client dial uses — one model, both sides. `VaultKeyStore::load` the client dial uses: one model, both sides.
Vanilla OpenSSH authenticated against that server, the Vanilla OpenSSH authenticated against that server, the
`known_hosts` fingerprint (written by the vanilla client) matched `known_hosts` fingerprint (written by the vanilla client) matched
the vault host key's fingerprint exactly, and the russh client's the vault host key's fingerprint exactly, and the russh client's
`check_server_key` observed the same fingerprint. `check_server_key` observed the same fingerprint.
- The client's *pin* — the expected host fingerprint — is **public - The client's *pin* (the expected host fingerprint) is **public
config, not vault material** (`check_server_key` policy + the config, not vault material** (`check_server_key` policy + the
known-hosts entry); only the server's *private* host key is held known-hosts entry); only the server's *private* host key is held
secret. The ADR's "assembly configures `Config::keys`" note can now secret. The ADR's "assembly configures `Config::keys`" note can now
@@ -192,39 +192,39 @@ with evidence". Evidence assembled:
- **Vanilla-tool compatibility of the plaintext**: the decrypted - **Vanilla-tool compatibility of the plaintext**: the decrypted
client-key PEM written to a 0600 temp file was accepted by vanilla client-key PEM written to a 0600 temp file was accepted by vanilla
`ssh -i` directly (auth succeeded; `Permission denied` never `ssh -i` directly (auth succeeded; `Permission denied` never
appeared — exit 255 only because the POC server intentionally serves appeared, with exit 255 only because the POC server intentionally serves
no exec). The at-rest bytes are, at rest *and* in motion, plain no exec). The at-rest bytes are, at rest *and* in motion, plain
OpenSSH key material. OpenSSH key material.
- Locked vault at server bootstrap → `LoadError::Locked` → **no - Locked vault at server bootstrap → `LoadError::Locked` → **no
server is constructed** — the fail-closed posture extends to the server is constructed**; the fail-closed posture extends to the
host-key boot path. host-key boot path.
## Hypothesis verdicts (pocs.md §POC-2) ## Hypothesis verdicts (pocs.md §POC-2)
- "OpenSSH private-key encoding as the inner format; alkvault - "OpenSSH private-key encoding as the inner format; alkvault
`encrypt`/`decrypt` (`EncryptedData`) as the outer wrapper" — `encrypt`/`decrypt` (`EncryptedData`) as the outer wrapper" is
**confirmed**, and strengthened by the 4-way comparison table (the **confirmed**, and strengthened by the 4-way comparison table (the
seed-only variant as a size optimization; the ssh-key self-encrypted seed-only variant as a size optimization; the ssh-key self-encrypted
variant recorded as a double-wrapping antipattern). variant recorded as a double-wrapping antipattern).
- "decrypt-at-dial → one-time `decode_secret_key` → zeroized handling - "decrypt-at-dial → one-time `decode_secret_key` → zeroized handling
for the dial's lifetime" — **confirmed** (ssh-key zeroizes key bytes for the dial's lifetime" is **confirmed** (ssh-key zeroizes key bytes
on drop; adapter owns plaintext zeroization at the seam; vault on drop; adapter owns plaintext zeroization at the seam; vault
in-memory state self-purges on `lock`). in-memory state self-purges on `lock`).
- "what happens locked / absent / rotation" — **confirmed as - "what happens locked / absent / rotation" is **confirmed as
fail-closed** with distinct, mappable error shapes; healthy rotation fail-closed** with distinct, mappable error shapes; healthy rotation
(v2→v3) preserves key identity. (v2→v3) preserves key identity.
- "Signer-shaped seam still worth anything — expected: no" — - "Signer-shaped seam still worth anything, expected answer: no" is
**dismissed with evidence** (no provided impl; hand-rolled splice **dismissed with evidence** (no provided impl; hand-rolled splice
convention; in-process vault gains nothing; runtime proof the seam convention; in-process vault gains nothing; runtime proof the seam
works anyway). works anyway).
- "Server and client host-key material riding the same model" — - "Server and client host-key material riding the same model" is
**confirmed** end to end, including vanilla-tool compatibility and **confirmed** end to end, including vanilla-tool compatibility and
locked-at-bootstrap fail-closed. locked-at-bootstrap fail-closed.
## API facts for the ADRs (all verified against crates.io sources) ## API facts for the ADRs (all verified against crates.io sources)
- **alkvault 0.1.0**: `VaultServiceHandle` is a sync, cloneable handle - **alkvault 0.1.0**: `VaultServiceHandle` is a sync, cloneable handle
(interior `Arc<RwLock>`; short-held locks — no tokio coupling); (interior `Arc<RwLock>`; short-held locks, no tokio coupling);
`unlock_new(word_count)` returns the phrase as `Zeroizing<String>`; `unlock_new(word_count)` returns the phrase as `Zeroizing<String>`;
`encrypt(&str, u32)` / `decrypt(&EncryptedData) -> Result<String>` / `encrypt(&str, u32)` / `decrypt(&EncryptedData) -> Result<String>` /
`rotate(&EncryptedData, to_version)`; current version 2, versions `rotate(&EncryptedData, to_version)`; current version 2, versions
@@ -235,16 +235,16 @@ with evidence". Evidence assembled:
`ssh-key/encryption` feature), PuTTY PPK, PKCS#5 and PKCS#8 (+ the `ssh-key/encryption` feature), PuTTY PPK, PKCS#5 and PKCS#8 (+ the
`BEGIN PRIVATE KEY` / `BEGIN ENCRYPTED PRIVATE KEY` PEM cases); `BEGIN PRIVATE KEY` / `BEGIN ENCRYPTED PRIVATE KEY` PEM cases);
`keys::encode_pkcs8_pem[_encrypted]` exist for writers (PKCS#8 `keys::encode_pkcs8_pem[_encrypted]` exist for writers (PKCS#8
support lives in *russh*, not in ssh-key — ssh-key rc11 has no support lives in *russh*, not in ssh-key, which at rc11 has no
`pkcs8` feature; the research note about ssh-key-own-PKCS#8 does not `pkcs8` feature; the research note about ssh-key-own-PKCS#8 does not
apply at this pin). `PrivateKey: signature::Signer<Signature>`, apply at this pin). `PrivateKey: signature::Signer<Signature>`,
`Signature::encoded()` via `signature::SignatureEncoding`; russh's `Signature::encoded()` via `signature::SignatureEncoding`; russh's
own signing (`helpers::sign_with_hash_alg`) is exactly own signing (`helpers::sign_with_hash_alg`) is exactly
`try_sign(keypair, msg)?.encoded()` — a vault signer can mirror it, `try_sign(keypair, msg)?.encoded()`, which a vault signer can mirror,
but `helpers` (and its `EncodedExt`) is crate-private; the public but `helpers` (and its `EncodedExt`) is crate-private; the public
path is `russh::keys::signature::Signer` + `russh::keys::ssh_encoding::Encode`. path is `russh::keys::signature::Signer` + `russh::keys::ssh_encoding::Encode`.
- **Server-side auth pipeline**: a failed/malformed signature produces - **Server-side auth pipeline**: a failed/malformed signature produces
`USERAUTH_FAILURE` *without* calling `auth_publickey` — handler `USERAUTH_FAILURE` *without* calling `auth_publickey`, so handler
hooks only fire on cryptographically valid signatures (POC-1 hooks only fire on cryptographically valid signatures (POC-1
established the "after verification" half; POC-2 adds "invalid sigs established the "after verification" half; POC-2 adds "invalid sigs
never reach the handler at all"). never reach the handler at all").
@@ -255,7 +255,7 @@ with evidence". Evidence assembled:
## OQ seeds carried forward ## OQ seeds carried forward
1. **alkvault `decrypt` returns plain `String`** — a zeroization gap at 1. **alkvault `decrypt` returns plain `String`**: a zeroization gap at
the seam (the vault's own key material is zeroized; the decrypted the seam (the vault's own key material is zeroized; the decrypted
*plaintext* is not until the adapter wraps it). Candidate fixes: *plaintext* is not until the adapter wraps it). Candidate fixes:
alkvault returns `Zeroizing<String>`, or the alkssh ADR documents alkvault returns `Zeroizing<String>`, or the alkssh ADR documents
@@ -267,17 +267,17 @@ with evidence". Evidence assembled:
`InvalidPath` `InvalidPath`
for unknown for unknown
versions is the fail-closed behavior). Rotation (at-rest version versions is the fail-closed behavior). Rotation (at-rest version
bump, key constant) is a storage-side operation — proven to bump, key constant) is a storage-side operation; proven to
preserve key identity (consistent with alkvault's own preserve key identity (consistent with alkvault's own
version-indexed rotation decision, alkvault ADR-021). version-indexed rotation decision, alkvault ADR-021).
3. **One vault, many roles?** The POC held client dial keys *and* the 3. **One vault, many roles?** The POC held client dial keys *and* the
server host key in one vault (one mnemonic). Whether the assembly server host key in one vault (one mnemonic). Whether the assembly
layer should run separate vault instances per role (client vs layer should run separate vault instances per role (client vs
server custody) is an assembly policy question, not an alkssh server custody) is an assembly policy question, not an alkssh
design constraint — recorded so phase 1 doesn't bake it in. design constraint, recorded so phase 1 doesn't bake it in.
4. **Never persist decrypted plaintext**: the vanilla-tool check 4. **Never persist decrypted plaintext**: the vanilla-tool check
wrote a decrypted key to a 0600 temp file (then zeroized and wrote a decrypted key to a 0600 temp file (then zeroized and
deleted it) — that is a test-only demonstration of the byte format. deleted it); that is a test-only demonstration of the byte format.
Adapters do not spill plaintext to disk; the vanilla-compatible PEM Adapters do not spill plaintext to disk; the vanilla-compatible PEM
is handed to processes only via the assembly layer if ever. is handed to processes only via the assembly layer if ever.
@@ -285,13 +285,13 @@ for unknown
Full output of a clean run at findings time: **39 checks, 5 groups** Full output of a clean run at findings time: **39 checks, 5 groups**
(at-rest shape 11, dial lifecycle 8, failure matrix 10, Signer seam 3, (at-rest shape 11, dial lifecycle 8, failure matrix 10, Signer seam 3,
host key + vanilla 7) — all PASS. `overall=true`, exit 0. host key + vanilla 7), all PASS. `overall=true`, exit 0.
## References ## References
- `pocs.md` §POC-2 (plan), `auth-bridge.md` §The client side (design — - `pocs.md` §POC-2 (plan), `auth-bridge.md` §The client side (design,
updated with a pointer to these verdicts), `russh.md` updated with a pointer to these verdicts), `russh.md`
(`client::Handle` authenticate methods — updated with the (`client::Handle` authenticate methods, updated with the
Signer-verdict pointer) Signer-verdict pointer)
- POC-1: `poc-1-auth-bridge.md` + `/workspace/alkssh-poc1` - POC-1: `poc-1-auth-bridge.md` + `/workspace/alkssh-poc1`
(server loopback shape reused) (server loopback shape reused)
+69 -69
View File
@@ -1,26 +1,26 @@
# POC-3 findings — the mapping bake-off: channel bridge through real sibling seams # POC-3 findings: the mapping bake-off, channel bridge through real sibling seams
Status: Complete (phase 0). POC source: `/workspace/alkssh-poc3` Status: Complete (phase 0). POC source: `/workspace/alkssh-poc3`
(standalone crate, disposable — never merges). All four stages ran (standalone crate, disposable, never merges). All four stages ran
against **vanilla clients** (OpenSSH 9.6 `ssh -L`/`-R`, real `git` against **vanilla clients** (OpenSSH 9.6 `ssh -L`/`-R`, real `git`
2.43, real `curl` 8.5) and the **published crates.io forms** of every 2.43, real `curl` 8.5) and the **published crates.io forms** of every
sibling — no path dependencies, no in-family forks. The doc records sibling, with no path dependencies and no in-family forks. The doc records
the evidence: three corrections to `mapping-table.md`, the russh the evidence: three corrections to `mapping-table.md`, the russh
0.64.1 runtime facts phase 1 must code around (the inline-dispatch 0.64.1 runtime facts phase 1 must code around (the inline-dispatch
rule), the identity/scope live check, and the OQ seeds. rule), the identity/scope live check, and the OQ seeds.
## Setup exercised ## Setup exercised
- russh **0.64.1** (crates.io, ring backend — the same published tree - russh **0.64.1** (crates.io, ring backend; the same published tree
POC-1/POC-2 pinned; re-verified still the newest release). Same POC-1/POC-2 pinned; re-verified still the newest release). Same
dependency tree as both prior POCs across all four stages. dependency tree as both prior POCs across all four stages.
- Sibling seams, published forms: **alkgit 0.1.0 + - Sibling seams, published forms: **alkgit 0.1.0 +
alkgit-substrate 0.1.0** (the shipped backend composition, with alkgit-substrate 0.1.0** (the shipped backend composition, with
`alkstore-mem` engine + `alkblobs::Store::mem_only()` object pool — `alkstore-mem` engine + `alkblobs::Store::mem_only()` object pool,
the substrate's own mem-assembly shape, `SubstrateAssembly::mem`), the substrate's own mem-assembly shape, `SubstrateAssembly::mem`),
**alksocks 0.3.1** (`local` feature — the shipped `tcp_dial`), **alksocks 0.3.1** (`local` feature, the shipped `tcp_dial`),
**alktunnels 0.4.1** (`local` feature — the shipped `local_dial`), **alktunnels 0.4.1** (`local` feature, the shipped `local_dial`),
**alktty 0.8.1** (`local` feature — the shipped `LocalTtyBackend` **alktty 0.8.1** (`local` feature, the shipped `LocalTtyBackend`
over portable-pty), **alkcall 0.10.1** (channels + identity types). over portable-pty), **alkcall 0.10.1** (channels + identity types).
- Servers per stage were built as **feature-shaped builds**: stage A's - Servers per stage were built as **feature-shaped builds**: stage A's
server serves only git-parseable exec (a git-only build), stage B's server serves only git-parseable exec (a git-only build), stage B's
@@ -33,7 +33,7 @@ rule), the identity/scope live check, and the OQ seeds.
- POC mechanics: single binary, stage functions returning check - POC mechanics: single binary, stage functions returning check
reports (POC-1's harness); full clean runs reproduced twice. reports (POC-1's harness); full clean runs reproduced twice.
## Stage A — git exec mapping (24/24 PASS) ## Stage A: git exec mapping (24/24 PASS)
### 1. Vanilla git works through the seam with zero seam fixups ### 1. Vanilla git works through the seam with zero seam fixups
@@ -47,7 +47,7 @@ vanilla git (GIT_SSH_COMMAND) → OpenSSH → russh server
Limits, registry, refs, packgen, ingest, false, pipeline) Limits, registry, refs, packgen, ingest, false, pipeline)
``` ```
with the **real alkgit-substrate mem composition** as the backend — with the **real alkgit-substrate mem composition** as the backend:
registry/ref-rows/packgen/ingest, an in-memory alkstore engine, and an registry/ref-rows/packgen/ingest, an in-memory alkstore engine, and an
in-memory object pool. The published alkgit seam needed no wrapper, no in-memory object pool. The published alkgit seam needed no wrapper, no
adapter glue beyond the command parse, and no dialect nudges. The adapter glue beyond the command parse, and no dialect nudges. The
@@ -56,9 +56,9 @@ ADR work) is confirmed.
### 2. The SSH exec-command grammar (the alkssh-side parse) ### 2. The SSH exec-command grammar (the alkssh-side parse)
What real OpenSSH git actually sends: `git-upload-pack '/owner/demo'` What real OpenSSH git actually sends: `git-upload-pack '/owner/demo'`,
— single-quoted repo path with a leading `/` (the parse strips quotes a single-quoted repo path with a leading `/` (the parse strips quotes
and the leading slash; no `\0host=` rides the SSH exec — the and the leading slash; no `\0host=` rides the SSH exec, so the
mapping-table's "same grammar as preamble.rs, without `\0host=`" is mapping-table's "same grammar as preamble.rs, without `\0host=`" is
exactly right). The POC grammar table (all rows verified): exactly right). The POC grammar table (all rows verified):
@@ -66,14 +66,14 @@ exactly right). The POC grammar table (all rows verified):
|---|---| |---|---|
| `git-upload-pack '/r'` / `git-receive-pack '/r'` / `git-upload-pack /r` | git parse, dispatched | | `git-upload-pack '/r'` / `git-receive-pack '/r'` / `git-upload-pack /r` | git parse, dispatched |
| `git-send-pack '/r'`, `git-upload-archive '/r'` | declined (unknown service, honest surface) | | `git-send-pack '/r'`, `git-upload-archive '/r'` | declined (unknown service, honest surface) |
| `git-upload-pack` (service-only, no repo) | declined — SSH exec must carry the repo | | `git-upload-pack` (service-only, no repo) | declined: SSH exec must carry the repo |
| `uptime` (non-git) | declined parse-then-fail, no resource alloc | | `uptime` (non-git) | declined parse-then-fail, no resource alloc |
### 3. Subsystem mapping row — corrected at the detail level ### 3. Subsystem mapping row (corrected at the detail level)
`mapping-table.md` says git subsystem names "arrive as either" (exec `mapping-table.md` says git subsystem names "arrive as either" (exec
or subsystem). On the SSH wire a subsystem request carries **only the or subsystem). On the SSH wire a subsystem request carries **only the
name** — no repository is available to resolve. The POC proves the name**, and no repository is available to resolve. The POC proves the
forced decline: `subsystem("git-upload-pack")` and `subsystem("sftp")` forced decline: `subsystem("git-upload-pack")` and `subsystem("sftp")`
both fail cleanly with a channel failure, with the finding recorded: both fail cleanly with a channel failure, with the finding recorded:
*over SSH, git always uses exec; a subsystem git request can never *over SSH, git always uses exec; a subsystem git request can never
@@ -85,21 +85,21 @@ The mapping-table row needs the detail fix.
- **Server handler callbacks run inline inside the session's packet - **Server handler callbacks run inline inside the session's packet
loop.** Blocking inside `exec_request`/`pty_request`/`shell_request` loop.** Blocking inside `exec_request`/`pty_request`/`shell_request`
(or any handler) suspends packet processing for the *entire (or any handler) suspends packet processing for the *entire
connection* — the awaited channel data is only routed by that same connection*: the awaited channel data is only routed by that same
loop, so an inline serve **deadlocks** (receive-pack pushes bytes loop, so an inline serve **deadlocks** (receive-pack pushes bytes
immediately after the exec request). Confirmed by construction: the immediately after the exec request). Confirmed by construction: the
serving entry is spawned and the handler returns immediately. This serving entry is spawned and the handler returns immediately. This
is the binding implementation constraint for the alkssh adapter: is the binding implementation constraint for the alkssh adapter:
**every serving path is `tokio::spawn` + handler returns fast**. **every serving path is `tokio::spawn` + handler returns fast**.
- The spawned task gets exit reporting through - The spawned task gets exit reporting through
**`Session::handle()`** — a cloneable server-side `Handle` **`Session::handle()`**, a cloneable server-side `Handle`
carrying `data` / `extended_data` / `eof` / `close` / carrying `data` / `extended_data` / `eof` / `close` /
`exit_status_request` / `exit_signal_request`. The exit-status `exit_status_request` / `exit_signal_request`. The exit-status
mapping row is implementable exactly as the table prescribes: send mapping row is implementable exactly as the table prescribes: send
`exit-status` before `close` from the serving task. `exit-status` before `close` from the serving task.
- The accepted-but-held `Channel` can be stored in the handler and - The accepted-but-held `Channel` can be stored in the handler and
taken later in the request callback (`channels.remove(&id)` → taken later in the request callback (`channels.remove(&id)` →
`into_stream()` in the spawned task) — no `wait()` anywhere `into_stream()` in the spawned task), with no `wait()` anywhere
(POC-1's caveat holds server-side too). (POC-1's caveat holds server-side too).
### 5. Exit-status fidelity, denial fidelity, vanilla diagnostics ### 5. Exit-status fidelity, denial fidelity, vanilla diagnostics
@@ -109,7 +109,7 @@ The mapping-table row needs the detail fix.
protocol byte* (no existence oracle) and vanilla git reports protocol byte* (no existence oracle) and vanilla git reports
`fatal: remote error: access denied: owner/absent`, exit 128. `fatal: remote error: access denied: owner/absent`, exit 128.
- A russh client exec against the absent repo observed - A russh client exec against the absent repo observed
**`ExitStatus{1}`** after the denial band — the exit-status flow is **`ExitStatus{1}`** after the denial band; the exit-status flow is
proven in both directions, including the failure leg. proven in both directions, including the failure leg.
- The git-only build's decline matrix held under vanilla ssh: - The git-only build's decline matrix held under vanilla ssh:
`ssh host uptime` failed with the parse-then-fail decline logged; `ssh host uptime` failed with the parse-then-fail decline logged;
@@ -117,7 +117,7 @@ The mapping-table row needs the detail fix.
replies (russh client observed `ChannelMsg::Failure`); the fail- replies (russh client observed `ChannelMsg::Failure`); the fail-
closed feature matrix of the mapping table is live end to end. closed feature matrix of the mapping table is live end to end.
## Stage B — SOCKS5-in-channel (6/6 PASS) ## Stage B: SOCKS5-in-channel (6/6 PASS)
### 1. `serve_socks5_over` works over a russh session channel ### 1. `serve_socks5_over` works over a russh session channel
@@ -129,32 +129,32 @@ Construction: server accepts the session channel, spawns the
domain-form targets). Client side: a local bridge listener pumps each domain-form targets). Client side: a local bridge listener pumps each
accepted TCP socket bidirectionally into one authenticated session accepted TCP socket bidirectionally into one authenticated session
channel. **Real `curl --socks5-hostname` through the whole chain** channel. **Real `curl --socks5-hostname` through the whole chain**
returned the endpoint body byte-identically — CONNECT over the SSH returned the endpoint body byte-identically: CONNECT over the SSH
wire, dialed at the server through the family seam. wire, dialed at the server through the family seam.
### 2. OpenSSH's own `-D` never speaks SOCKS on the wire — mapping row corrected ### 2. OpenSSH's own `-D` never speaks SOCKS on the wire (mapping row corrected)
This is a **mapping-table correction**: vanilla `ssh -D` performs the This is a **mapping-table correction**: vanilla `ssh -D` performs the
SOCKS5 handshake *client-side* and then opens plain **`direct-tcpip` SOCKS5 handshake *client-side* and then opens plain **`direct-tcpip`
channels** per connection. The `session` → SOCKS-in-channel row is channels** per connection. The `session` → SOCKS-in-channel row is
therefore the family-native client shape (an embedder/alktty-style therefore the family-native client shape (an embedder/alktty-style
client speaking SOCKS5 on the channel), not what a vanilla `ssh -D` client speaking SOCKS5 on the channel), not what a vanilla `ssh -D`
emits — vanilla `-D` traffic takes the stage-C row (direct-tcpip → emits; vanilla `-D` traffic takes the stage-C row (direct-tcpip →
egress). Both rows are real; the table should say which client shape egress). Both rows are real; the table should say which client shape
each serves. (POC evidence: this stage's proxy uses the session each serves. (POC evidence: this stage's proxy uses the session
channel; stage C's `-L`-style row serves forwarded and SOCKS-originated channel; stage C's `-L`-style row serves forwarded and SOCKS-originated
direct-tcpip alike.) direct-tcpip alike.)
### 3. The dispatch split is real — and needs a bounded detect window ### 3. The dispatch split is real and needs a bounded detect window
Parse-then-route (stage A) and SOCKS-detect (this stage) are the two Parse-then-route (stage A) and SOCKS-detect (this stage) are the two
session-channel dispatch modes the mapping table predicted, and both session-channel dispatch modes the mapping table predicted, and both
work. The detect runs in a spawned task with a bounded window (500 ms work. The detect runs in a spawned task with a bounded window (500 ms
in the POC): the first byte decides the route; a silent channel is in the POC): the first byte decides the route; a silent channel is
declined. Two consequences for the ADR: (a) the detect must not block declined. Two consequences for the ADR: (a) the detect must not block
inline (the §A.4 deadlock rule applies — the sniffed byte arrives via inline (the §A.4 deadlock rule applies: the sniffed byte arrives via
the same loop that awaits it); (b) the *detect window* is a design the same loop that awaits it); (b) the *detect window* is a design
shape to pin — an OQ seed: whether the dispatch is a first-byte sniff, shape to pin, an OQ seed: whether the dispatch is a first-byte sniff,
an in-band preamble, or an exec-request marker chosen by the assembly. an in-band preamble, or an exec-request marker chosen by the assembly.
### 4. Refusals are RFC-faithful through the seam ### 4. Refusals are RFC-faithful through the seam
@@ -165,15 +165,15 @@ curl exits **97** (proxy-connection-not-allowed class) with no data
crossing. Backpressure/throughput: **8 MiB through the session-channel crossing. Backpressure/throughput: **8 MiB through the session-channel
proxy in ~585 ms** (in-process route; the 1 MiB channels-reassembly proxy in ~585 ms** (in-process route; the 1 MiB channels-reassembly
leg is not on this path). Non-SOCKS first byte on a session channel → leg is not on this path). Non-SOCKS first byte on a session channel →
declined, logged — no HTTP serving happens on the SOCKS route. declined, logged; no HTTP serving happens on the SOCKS route.
## Stage C — direct-tcpip via alktunnels route 1 (8/8 PASS) ## Stage C: direct-tcpip via alktunnels route 1 (8/8 PASS)
### 1. The bridged route works end to end with the published crates ### 1. The bridged route works end to end with the published crates
Route 1 (adapter-side bridge) exercised as the mapping table's route Route 1 (adapter-side bridge) exercised as the mapping table's route
note describes: the russh server holds a **real `ChannelClient`** into note describes: the russh server holds a **real `ChannelClient`** into
a family fabric — an in-process `alk/channels` connection whose a family fabric, an in-process `alk/channels` connection whose
serving side (the `ChannelsAdapter` accept end) registers serving side (the `ChannelsAdapter` accept end) registers
`ChannelOperations` + `register_tunnel_direct_openable` with the `ChannelOperations` + `register_tunnel_direct_openable` with the
shipped `alktunnels::local::local_dial()` injected. Per vanilla `ssh shipped `alktunnels::local::local_dial()` injected. Per vanilla `ssh
@@ -188,27 +188,27 @@ channel_open_direct_tcpip → TunnelSession::open_direct(
Vanilla `ssh -L` + `curl` to the bridged port returned the endpoint Vanilla `ssh -L` + `curl` to the bridged port returned the endpoint
body byte-identically; **8 MiB through the bridge in ~333 ms**. The body byte-identically; **8 MiB through the bridge in ~333 ms**. The
`direct_egress` precedent in alksocks' `composed` feature is the same `direct_egress` precedent in alksocks' `composed` feature is the same
shape — the family precedent survives contact with the SSH door. shape; the family precedent survives contact with the SSH door.
### 2. Failure shapes at both ends of the bridge ### 2. Failure shapes at both ends of the bridge
- Fabric open failure → the SSH channel is rejected **explicitly** - Fabric open failure → the SSH channel is rejected **explicitly**
(`ChannelOpenHandle::reject(ConnectFailed)`); accept is possible (`ChannelOpenHandle::reject(ConnectFailed)`); accept is possible
only after `open_direct` succeeds because `accept(mut self)` only after `open_direct` succeeds because `accept(mut self)`
consumes the handle — so the handle moves into the spawned task. consumes the handle, so the handle moves into the spawned task.
A vanilla client dials a dead target and gets a clean curl failure A vanilla client dials a dead target and gets a clean curl failure
(exit 56) instead of a hang. (exit 56) instead of a hang.
- The `-R` ingress direction is **declined** on this egress-only - The `-R` ingress direction is **declined** on this egress-only
build: `Handler::tcpip_forward` returns false (the global request build: `Handler::tcpip_forward` returns false (the global request
refuses), vanilla `ssh -R` dies with `remote port forwarding refuses), vanilla `ssh -R` dies with `remote port forwarding
failed for listen port` (exit 255), and the event log records the failed for listen port` (exit 255), and the event log records the
refusal — honest advertisement, gate-before-wire. refusal: honest advertisement, gate-before-wire.
- Unfeatured session-channel opens drop (auto-reject) unchanged from - Unfeatured session-channel opens drop (auto-reject) unchanged from
POC-1 on this and the other stage builds. POC-1 on this and the other stage builds.
### 3. Identity/scope propagation observed live ### 3. Identity/scope propagation observed live
The direct establisher's identity witness recorded **`u-writer`** — The direct establisher's identity witness recorded **`u-writer`**;
the SSH-authenticated identity rides the transport connection into the SSH-authenticated identity rides the transport connection into
the channels layer (CF-005 propagation), and the establisher's the channels layer (CF-005 propagation), and the establisher's
per-call auth (CF-006) sees it. The visible-surface invariant's per-call auth (CF-006) sees it. The visible-surface invariant's
@@ -227,30 +227,30 @@ only real lift, and the alkforge assembly will build exactly this):
transport; `ChannelClient::from_connection` on the alkssh side with transport; `ChannelClient::from_connection` on the alkssh side with
the identity attached before dialing. the identity attached before dialing.
## Stage D — session/pty mapping (13/13 PASS) ## Stage D: session/pty mapping (13/13 PASS)
### 1. The mapping is a translation bridge, not a pump — mapping-table note confirmed with a shape ### 1. The mapping is a translation bridge, not a pump (mapping-table note confirmed with a shape)
alktty's data plane is **chunk-framed in both directions** alktty's data plane is **chunk-framed in both directions**
(STREAM_STDIN/STDOUT/STDERR/CTRL_IN/CTRL_OUT, 5-byte header + payload (STREAM_STDIN/STDOUT/STDERR/CTRL_IN/CTRL_OUT, 5-byte header + payload;
— `alktty::wire`). The SSH mapping therefore wraps the channel with `alktty::wire`). The SSH mapping therefore wraps the channel with
two small adapters: SSH data/EOF → stdin chunks (+ zero-length EOF two small adapters: SSH data/EOF → stdin chunks (+ zero-length EOF
sentinel); alktty stdout chunks → SSH data, stderr chunks → SSH sentinel); alktty stdout chunks → SSH data, stderr chunks → SSH
`extended_data(1)`, ctrl-out exit chunk → SSH `exit-status` request + `extended_data(1)`, ctrl-out exit chunk → SSH `exit-status` request +
close. `drive_session_pre_negotiated(writer, reader, NegotiateRequest close. `drive_session_pre_negotiated(writer, reader, NegotiateRequest
built locally, backends, ownership, identity)` — the exact producer built locally, backends, ownership, identity)`, the exact producer
construct the mapping table names — then runs the real session. Proven construct the mapping table names, then runs the real session. Proven
against a **real PTY** (`LocalTtyBackend` over portable-pty): against a **real PTY** (`LocalTtyBackend` over portable-pty):
- `pty_request` args → `TerminalParamsWire` (`term`/cols/rows/ - `pty_request` args → `TerminalParamsWire` (`term`/cols/rows/
pixels; SSH modes → the reserved `{}` JSON — an alktty-side pixels; SSH modes → the reserved `{}` JSON, an alktty-side
reservation, alktty OQ-44; modes are deferred there) — live: bash on reservation, alktty OQ-44; modes are deferred there); live: bash on
the PTY answered `stty size` with the requested 24×80. the PTY answered `stty size` with the requested 24×80.
- Keystrokes ride in, PTY echo and shell output ride out (real bash - Keystrokes ride in, PTY echo and shell output ride out (real bash
prompt captured in the run output). prompt captured in the run output).
- `window_change_request` → `ControlMessage::Resize` as a CTRL_IN - `window_change_request` → `ControlMessage::Resize` as a CTRL_IN
chunk → the PTY resized: `stty size` flipped to `40 120` after the chunk → the PTY resized: `stty size` flipped to `40 120` after the
client sent `window_change(120, 40)` — the mapping row proven client sent `window_change(120, 40)`, the mapping row proven
behaviorally. behaviorally.
- Pipe-mode `exec_request`: stdout rode SSH data, stderr rode SSH - Pipe-mode `exec_request`: stdout rode SSH data, stderr rode SSH
`extended_data(1)` (client received both distinctly), exit `extended_data(1)` (client received both distinctly), exit
@@ -259,11 +259,11 @@ against a **real PTY** (`LocalTtyBackend` over portable-pty):
local backend produces. local backend produces.
- `signal(TERM)` → `ControlMessage::Signal` CTRL_IN chunk → the - `signal(TERM)` → `ControlMessage::Signal` CTRL_IN chunk → the
backend killed the session; the exit chunk carried the negative backend killed the session; the exit chunk carried the negative
code and the SSH reply was an **`exit-signal`** request — the code and the SSH reply was an **`exit-signal`** request, matching the
mapping-table's "only reachable in signal-termination cases" row, mapping-table's "only reachable in signal-termination cases" row,
now with live wire evidence. now with live wire evidence.
- The git-parseable exec declined in the session-only build - The git-parseable exec declined in the session-only build
(parse-then-route: the git parse is the `git` feature's gate) — (parse-then-route: the git parse is the `git` feature's gate);
the feature-split failure matrix holds in this build too. the feature-split failure matrix holds in this build too.
### 2. Signal/exit fidelity over the alktty exit-code convention ### 2. Signal/exit fidelity over the alktty exit-code convention
@@ -272,14 +272,14 @@ The exit chunk is `{"type":"exit","code":N}` (alktty ADR-004, ported
from alknet ADR-055). Findings of this stage, verified against the from alknet ADR-055). Findings of this stage, verified against the
published sources: published sources:
- **Pipe path — the negative code is the signal encoding.** Pipe-mode - **Pipe path: the negative code is the signal encoding.** Pipe-mode
signal-terminated children arrive as the negative signal number signal-terminated children arrive as the negative signal number
verbatim from std's `ExitStatusExt` (`pipe.rs::exit_code_from` maps verbatim from std's `ExitStatusExt` (`pipe.rs::exit_code_from` maps
`ExitStatus::signal()` to `−sig`; the alknet ADR-055 convention). `ExitStatus::signal()` to `−sig`; the alknet ADR-055 convention).
So SSH `exit-signal` needs no wire change: the adapter's check So SSH `exit-signal` needs no wire change: the adapter's check
mapped `−15` → `exit-signal("TERM")` — the name derivation is a mapped `−15` → `exit-signal("TERM")`; the name derivation is a
server-side number→name table lookup, not an alktty ask. server-side number→name table lookup, not an alktty ask.
- **PTY path — the signal number does not survive the backend.** - **PTY path: the signal number does not survive the backend.**
portable-pty 0.9's `ExitStatus` conversion records portable-pty 0.9's `ExitStatus` conversion records
`code = status.code().unwrap_or(1)` → **1** for signal-terminated `code = status.code().unwrap_or(1)` → **1** for signal-terminated
children, keeping the signal only in a separate humanized children, keeping the signal only in a separate humanized
@@ -288,16 +288,16 @@ published sources:
through portable-pty 0.9's trait surface). Alktty's own PTY signal through portable-pty 0.9's trait surface). Alktty's own PTY signal
tests assert `code != 0`, never the encoding. Consequence: faithful tests assert `code != 0`, never the encoding. Consequence: faithful
`exit-signal` for signal-killed PTY sessions needs the alktty `exit-signal` for signal-killed PTY sessions needs the alktty
backend to conform to its own ADR's negative-code convention — an backend to conform to its own ADR's negative-code convention,
alktty/portable-pty ask with tty/pty-grounded justification (upstream an alktty/portable-pty ask with tty/pty-grounded justification (upstream
trackers; the alkssh mapping adapts automatically, no hedge needed). trackers; the alkssh mapping adapts automatically, no hedge needed).
- **The `−1` sentinel** (backend wait failure) collides with a real - **The `−1` sentinel** (backend wait failure) collides with a real
SIGHUP termination (signal 1) — documented in alktty ADR-004 §4, SIGHUP termination (signal 1), documented in alktty ADR-004 §4,
with an additive `exit_error` control type as its recorded extension with an additive `exit_error` control type as its recorded extension
seam. Alkssh renders `−1` as `exit-status(255)` — it fabricates no seam. Alkssh renders `−1` as `exit-status(255)`, fabricating no
signal for a wait failure. signal for a wait failure.
- **Pipe-mode signals are pid-only** (a SIGTERM to `sh -c "sleep 60"` - **Pipe-mode signals are pid-only** (a SIGTERM to `sh -c "sleep 60"`
leaves the orphaned `sleep` holding the stdout pipe — no exit chunk leaves the orphaned `sleep` holding the stdout pipe, with no exit chunk
until the whole group dies); PTY-mode signals reach the process until the whole group dies); PTY-mode signals reach the process
group (correct terminal semantics). The signal row's fidelity group (correct terminal semantics). The signal row's fidelity
depends on the tty/pipe mode split the table already notes. depends on the tty/pipe mode split the table already notes.
@@ -306,38 +306,38 @@ published sources:
- russh client has `Channel::exec()` (there is no `request_exec`); - russh client has `Channel::exec()` (there is no `request_exec`);
pty/subsystem request *failures* surface as `ChannelMsg::Failure` pty/subsystem request *failures* surface as `ChannelMsg::Failure`
via `wait()` — **not** as `Err` from the request call; the caller via `wait()` (not as `Err` from the request call); the caller
must read the channel until Success/Failure. must read the channel until Success/Failure.
- `window_change`/`signal` are fire-and-forget (no want-reply), and - `window_change`/`signal` are fire-and-forget (no want-reply), and
must be sent **before** `into_stream()` (after the conversion the must be sent **before** `into_stream()` (after the conversion the
Channel is consumed) — server-side they carry no SSH reply, so the Channel is consumed); server-side they carry no SSH reply, so the
handler must not send one. handler must not send one.
- The server handler loop dispatches `CHANNEL_SUCCESS`-style replies - The server handler loop dispatches `CHANNEL_SUCCESS`-style replies
only through explicit handler calls; request-level failure replies only through explicit handler calls; request-level failure replies
in stage A/D all used `Session::channel_failure`. in stage A/D all used `Session::channel_failure`.
- The channel message loop client-side delivers interleaved - The channel message loop client-side delivers interleaved
Data/ExtendedData/Success/ExitStatus on one queue — drain to the Data/ExtendedData/Success/ExitStatus on one queue; drain to the
expected message, `continue` past the rest (the POC hit the expected message, `continue` past the rest (the POC hit the
break-on-first-message trap twice; noted so the alkssh consumer break-on-first-message trap twice; noted so the alkssh consumer
half doesn't). half doesn't).
## Hypothesis verdicts (pocs.md §POC-3) ## Hypothesis verdicts (pocs.md §POC-3)
- "one-seam-per-mapping hypothesis" — **confirmed** across all four - "one-seam-per-mapping hypothesis" is **confirmed** across all four
seams with zero seam fixups; the mapping glue is adapter-side seams with zero seam fixups; the mapping glue is adapter-side
(command parse, chunk translation, handle plumbing). (command parse, chunk translation, handle plumbing).
- "parse-then-route dispatch" — **confirmed** (stage A), with the - "parse-then-route dispatch" is **confirmed** (stage A), with the
SOCKS-detect variant confirmed in stage B plus the bounded-window SOCKS-detect variant confirmed in stage B plus the bounded-window
and spawn-not-block constraints. and spawn-not-block constraints.
- "EOF/close/signal fidelity end to end" — **confirmed**: exec → - "EOF/close/signal fidelity end to end" is **confirmed**: exec →
serve → exit-status (0 and 1 legs), exit-signal for signal serve → exit-status (0 and 1 legs), exit-signal for signal
termination, close mapping, refusal diagnostics at both bridge ends. termination, close mapping, refusal diagnostics at both bridge ends.
- "stderr semantics in pipe vs PTY mode" — **confirmed** exactly as - "stderr semantics in pipe vs PTY mode" is **confirmed** exactly as
the table describes (separate extended_data in pipe; merged under PTY). the table describes (separate extended_data in pipe; merged under PTY).
- "throughput sanity" — **confirmed** with margin: 8 MiB in ~0.3–0.6 s - "throughput sanity" is **confirmed** with margin: 8 MiB in ~0.3–0.6 s
per bridged route. per bridged route.
- "stage D may surface PTY-merge stderr surprises that reshape the - "stage D may surface PTY-merge stderr surprises that reshape the
request-level mapping rows" — partially: no surprises in the request-level mapping rows": partially. No surprises in the
PTY/pipe split, but the **signal/exit-fidelity details** and the PTY/pipe split, but the **signal/exit-fidelity details** and the
**translation-bridge shape** (two adapters, not a pump) do reshape **translation-bridge shape** (two adapters, not a pump) do reshape
the session row's implementation notes. the session row's implementation notes.
@@ -345,7 +345,7 @@ published sources:
## Corrections recorded against phase 0 research ## Corrections recorded against phase 0 research
1. `mapping-table.md` §channel-type rows: the git **subsystem** claim 1. `mapping-table.md` §channel-type rows: the git **subsystem** claim
("git-upload-pack etc. arrive as either") is wrong over SSH — ("git-upload-pack etc. arrive as either") is wrong over SSH:
subsystem cannot carry the repo id, so even a git service name must subsystem cannot carry the repo id, so even a git service name must
decline. Git over SSH is exec-only. decline. Git over SSH is exec-only.
2. `mapping-table.md`: `ssh -D` from a vanilla client does **not** 2. `mapping-table.md`: `ssh -D` from a vanilla client does **not**
@@ -356,19 +356,19 @@ published sources:
alktty bridge is a **translation bridge** (chunk framing both alktty bridge is a **translation bridge** (chunk framing both
directions), not a byte pump; plus the exit/signal fidelity directions), not a byte pump; plus the exit/signal fidelity
findings above (pipe-path `−N` encoding holds; PTY path reduces findings above (pipe-path `−N` encoding holds; PTY path reduces
signal termination to a plain code — portable-pty's exit surface). signal termination to a plain code, portable-pty's exit surface).
## OQ seeds carried forward ## OQ seeds carried forward
1. Session-channel dispatch shape for SOCKS (first-byte sniff vs 1. Session-channel dispatch shape for SOCKS (first-byte sniff vs
in-band preamble vs exec marker; the detect window's bound). in-band preamble vs exec marker; the detect window's bound).
2. Exit/signal fidelity over the alktty exit-code convention (the 2. Exit/signal fidelity over the alktty exit-code convention (the
`−N` encoding holds on the pipe path — name derivation is `−N` encoding holds on the pipe path, and the name derivation is
server-side; the PTY path's signal-identity loss is an server-side; the PTY path's signal-identity loss is an
alktty/portable-pty matter; pipe-mode pid-only vs PTY alktty/portable-pty matter; pipe-mode pid-only vs PTY
process-group scope). process-group scope).
3. Shell command convention (already seeded; the POC's stand-in — 3. Shell command convention (already seeded; the POC's stand-in,
`bash` for shell, `sh -c <cmd>` for exec — is an explicit assembly `bash` for shell and `sh -c <cmd>` for exec, is an explicit assembly
policy, not wire-derived). policy, not wire-derived).
4. The `-R` listen flow (forwarded-tcpip mapping): untouched by the 4. The `-R` listen flow (forwarded-tcpip mapping): untouched by the
POC beyond the refusal shape; route choice (ingress via POC beyond the refusal shape; route choice (ingress via
@@ -381,7 +381,7 @@ published sources:
## Reference run ## Reference run
Full clean run at findings time: **51 checks across 4 stages — 24 + 6 Full clean run at findings time: **51 checks across 4 stages: 24 + 6
+ 8 + 13, all PASS, `overall=true`, exit 0** (reproduced twice; + 8 + 13, all PASS, `overall=true`, exit 0** (reproduced twice;
second run byte-identical PASS set with different ephemeral ports). second run byte-identical PASS set with different ephemeral ports).
Server-side event logs captured per stage in the run output (the Server-side event logs captured per stage in the run output (the
@@ -389,7 +389,7 @@ diagnostics quoted above are verbatim from that run).
## References ## References
- `pocs.md` §POC-3 (plan), `mapping-table.md` (the rows under test — - `pocs.md` §POC-3 (plan), `mapping-table.md` (the rows under test,
three corrections recorded above), `poc-1-auth-bridge.md` (the auth three corrections recorded above), `poc-1-auth-bridge.md` (the auth
loopback everything rides), `russh.md` (API inventory) loopback everything rides), `russh.md` (API inventory)
- POC source: `/workspace/alkssh-poc3` (`src/stage_{a_git,b_socks, - POC source: `/workspace/alkssh-poc3` (`src/stage_{a_git,b_socks,
+32 -32
View File
@@ -1,4 +1,4 @@
# POCs — phase 0 validation plan # POCs: phase 0 validation plan
Status: **Complete** (phase 0). All three POCs ran against the current Status: **Complete** (phase 0). All three POCs ran against the current
published russh (0.64.1) and passed; the findings docs published russh (0.64.1) and passed; the findings docs
@@ -6,17 +6,17 @@ published russh (0.64.1) and passed; the findings docs
`poc-3-mapping-bakeoff.md`) are canonical and supersede the planned `poc-3-mapping-bakeoff.md`) are canonical and supersede the planned
hypothesis wording below wherever they record corrections. POC source hypothesis wording below wherever they record corrections. POC source
is disposable and never merges to main. Mode for all: **standalone** is disposable and never merges to main. Mode for all: **standalone**
(repos have no code to build on yet) — scratch cargo projects at (repos have no code to build on yet), scratch cargo projects at
`/workspace/<poc-name>` unless a POC explicitly builds on repo code `/workspace/<poc-name>` unless a POC explicitly builds on repo code
(then branch mode per `docs/sdd_process.md`). (then branch mode per `docs/sdd_process.md`).
The hypotheses below are what phase 1's ADRs will cite. If a POC The hypotheses below are what phase 1's ADRs will cite. If a POC
refutes a hypothesis, the finding doc records the refutation — that is refutes a hypothesis, the finding doc records the refutation, which is
the point of running it. the point of running it.
## POC-1 — auth bridge + fail-closed declines end to end ## POC-1: auth bridge + fail-closed declines end to end
Findings: `poc-1-auth-bridge.md` (complete — 31/31; all hypotheses Findings: `poc-1-auth-bridge.md` (complete: 31/31; all hypotheses
confirmed, one correction to the research docs' russh default-impl confirmed, one correction to the research docs' russh default-impl
assumption). POC source: `/workspace/alkssh-poc1`. assumption). POC source: `/workspace/alkssh-poc1`.
@@ -42,9 +42,9 @@ keys; russh client available as a secondary client for
with OpenSSH's probe-then-sign flow. with OpenSSH's probe-then-sign flow.
- Decline matrix: `auth_none`/`password`/`keyboard-interactive` - Decline matrix: `auth_none`/`password`/`keyboard-interactive`
declined (0.64.1 correction: the *default impls already reject* at declined (0.64.1 correction: the *default impls already reject* at
this russh — only `auth_publickey_offered` defaults to accept; this russh: only `auth_publickey_offered` defaults to accept;
`poc-1-auth-bridge.md` §3. The family contract still requires `poc-1-auth-bridge.md` §3). The family contract still requires
implementing every hook explicitly — the pin is a one-way door); implementing every hook explicitly, since the pin is a one-way door;
`Config::methods` advertises `publickey` only. `Config::methods` advertises `publickey` only.
- `ChannelOpenHandle` drop = auto-reject semantics; unfeatured - `ChannelOpenHandle` drop = auto-reject semantics; unfeatured
channel-type declines observable by vanilla `ssh` channel-type declines observable by vanilla `ssh`
@@ -56,12 +56,12 @@ keys; russh client available as a secondary client for
uncertainty is fingerprint-format parity details, which this either uncertainty is fingerprint-format parity details, which this either
confirms or corrects. confirms or corrects.
## POC-2 — client-side at-rest key handling (vault-backed storage) ## POC-2: client-side at-rest key handling (vault-backed storage)
Findings: `poc-2-vault-keys.md` (complete — 39/39; OpenSSH-PEM-inside- Findings: `poc-2-vault-keys.md` (complete: 39/39; OpenSSH-PEM-inside-
`EncryptedData` confirmed, Signer seam dismissed with evidence). `EncryptedData` confirmed, Signer seam dismissed with evidence).
**Question:** how are held SSH keys stored and used — the vault as **Question:** how are held SSH keys stored and used: the vault as
pure at-rest encryption (the key is just another bearer-token-class pure at-rest encryption (the key is just another bearer-token-class
secret: opaque bytes, vault-encrypted at rest, decrypted at use), what secret: opaque bytes, vault-encrypted at rest, decrypted at use), what
byte format sits inside, and how does the decrypt→dial lifecycle byte format sits inside, and how does the decrypt→dial lifecycle
@@ -82,39 +82,39 @@ the key's fingerprint through the same stub `IdentityProvider`).
→ zeroized handling for the dial's lifetime; what happens locked / → zeroized handling for the dial's lifetime; what happens locked /
absent (fail-closed no-dial) / mid-session unlock rotation. absent (fail-closed no-dial) / mid-session unlock rotation.
- Whether a `Signer`-shaped seam is still worth anything given the - Whether a `Signer`-shaped seam is still worth anything given the
bytes are handled client-side at use anyway (expected answer: no — bytes are handled client-side at use anyway (expected answer: no,
recorded so the ADR can dismiss it with evidence). recorded so the ADR can dismiss it with evidence).
- Server and client host-key material riding the same model. - Server and client host-key material riding the same model.
**Scope note:** the vault is for secrets *we* hold — client-side **Scope note:** the vault is for secrets *we* hold (client-side
outgoing keys and the server's own host key — and is explicitly not outgoing keys and the server's own host key) and is explicitly not
involved in resolving *clients'* fingerprints (that path is the involved in resolving *clients'* fingerprints (that path is the
assembly-layer `IdentityProvider`). The vault's derivation surface assembly-layer `IdentityProvider`). The vault's derivation surface
(`derive_ed25519`, SLIP-0010 paths) is not on this path either — that (`derive_ed25519`, SLIP-0010 paths) is not on this path either; that
is a different alkvault feature, not SSH key custody. Kept as its own is a different alkvault feature, not SSH key custody. Kept as its own
POC because its failure modes (at-rest/lifecycle, zeroization, lock POC because its failure modes (at-rest/lifecycle, zeroization, lock
behavior) are unrelated to the auth-flow failure modes of POC-1. behavior) are unrelated to the auth-flow failure modes of POC-1.
**Hypothesized outcome:** OpenSSH-encoded key inside alkvault **Hypothesized outcome:** OpenSSH-encoded key inside alkvault
`EncryptedData`, one-time `decode_secret_key` at dial, zeroizing `EncryptedData`, one-time `decode_secret_key` at dial, zeroizing
wrapper — confirming the alkhttp ADR-014 analogy end to end. wrapper, confirming the alkhttp ADR-014 analogy end to end.
## POC-3 — the mapping bake-off: channel bridge through a real sibling seam ## POC-3: the mapping bake-off, channel bridge through a real sibling seam
Findings: `poc-3-mapping-bakeoff.md` (complete — 51/51 across stages Findings: `poc-3-mapping-bakeoff.md` (complete: 51/51 across stages
A–D; every hypothesis confirmed, three mapping-table corrections A–D; every hypothesis confirmed, three mapping-table corrections
recorded, plus the russh inline-dispatch rule). POC source: recorded, plus the russh inline-dispatch rule). POC source:
`/workspace/alkssh-poc3`. `/workspace/alkssh-poc3`.
**Question:** does "russh channel duplex → family serve-over-a-stream **Question:** does "russh channel duplex → family serve-over-a-stream
entry" actually work against the real published seams — and which of entry" actually work against the real published seams, and which of
the mapping-table's route choices survives contact with reality? the mapping-table's route choices survives contact with reality?
**Construction:** standalone, but *depending on the real sibling **Construction:** standalone, but *depending on the real sibling
crates* (alktty/alksocks/alkgit + alkcall types via their published crates* (alktty/alksocks/alkgit + alkcall types via their published
forms). russh server loopback + vanilla clients; per stage: forms). russh server loopback + vanilla clients; per stage:
- **Stage A — git exec mapping** (the alkforge-critical path): vanilla - **Stage A, git exec mapping** (the alkforge-critical path): vanilla
`git clone/push` over `ssh://` → russh server → parse exec command → `git clone/push` over `ssh://` → russh server → parse exec command →
`serve_duplex_resolved(...)` over the split channel halves with stub `serve_duplex_resolved(...)` over the split channel halves with stub
`GitRefs`/`GitPackGen`/`GitPackIngest` (or the substrate composition `GitRefs`/`GitPackGen`/`GitPackIngest` (or the substrate composition
@@ -122,26 +122,26 @@ forms). russh server loopback + vanilla clients; per stage:
(server-speaks-first advertisement) dialect satisfies vanilla git's (server-speaks-first advertisement) dialect satisfies vanilla git's
expectations over exec; channel EOF/close maps to session expectations over exec; channel EOF/close maps to session
termination; exit-status flows. termination; exit-status flows.
- **Stage B — `-D` mapping**: russh server, feature `dynamic-forward`- - **Stage B, `-D` mapping**: russh server, feature `dynamic-forward`-
shaped build → `serve_socks5_over` over a session channel → `curl` shaped build → `serve_socks5_over` over a session channel → `curl`
through the SOCKS5 proxy to a local HTTP endpoint. Confirms: the through the SOCKS5 proxy to a local HTTP endpoint. Confirms: the
dispatch-split inside `session` (parse-then-route vs SOCKS-detect), dispatch-split inside `session` (parse-then-route vs SOCKS-detect),
duplex backpressure under throughput. duplex backpressure under throughput.
- **Stage C — `-L` mapping**: `direct-tcpip` → the mapping-table's two - **Stage C, `-L` mapping**: `direct-tcpip` → the mapping-table's two
alktunnels routes (adapter-side `TunnelSession::open_direct` via a alktunnels routes (adapter-side `TunnelSession::open_direct` via a
real `ChannelClient` vs local-dial stand-in). Whichever route the real `ChannelClient` vs local-dial stand-in). Whichever route the
POC exercises informs the phase 1 ADR on the presence/absence of a POC exercises informs the phase 1 ADR on the presence/absence of a
serve-over-stream entry in alktunnels. serve-over-stream entry in alktunnels.
- **Stage D — session/pty mapping**, final stage (ran in full): shell, - **Stage D, session/pty mapping**, final stage (ran in full): shell,
exec, window-change and signal mapping against a real PTY exec, window-change and signal mapping against a real PTY
(`LocalTtyBackend`, alktty `local`). The "optional" hedge in the (`LocalTtyBackend`, alktty `local`). The "optional" hedge in the
original plan was unnecessary — stage D completed 13/13 and produced original plan was unnecessary: stage D completed 13/13 and produced
the load-bearing session-row corrections: the alktty bridge is a the load-bearing session-row corrections: the alktty bridge is a
**translation bridge** (chunk framing both directions, not a byte **translation bridge** (chunk framing both directions, not a byte
pump) and the exit/signal findings over the alktty exit-code pump) and the exit/signal findings over the alktty exit-code
convention (pipe-path signal termination rides the negative code convention (pipe-path signal termination rides the negative code
`−N`; the PTY path reduces signal termination to a plain exit `−N`; the PTY path reduces signal termination to a plain exit
code — `poc-3-mapping-bakeoff.md` §D.2). code, per `poc-3-mapping-bakeoff.md` §D.2).
**Validates:** the one-seam-per-mapping hypothesis; the parse-then- **Validates:** the one-seam-per-mapping hypothesis; the parse-then-
route dispatch; stderr semantics in pipe vs PTY mode; EOF/close/signal route dispatch; stderr semantics in pipe vs PTY mode; EOF/close/signal
@@ -160,14 +160,14 @@ vanilla-SSH server and consumes its surface (exec/pty channels,
`direct-tcpip` opens, the `tcpip_forward` + accepted-`forwarded-tcpip` `direct-tcpip` opens, the `tcpip_forward` + accepted-`forwarded-tcpip`
listener flow). No dedicated POC was needed: the POCs exercised the listener flow). No dedicated POC was needed: the POCs exercised the
client API throughout (POC-1's russh-client auth leg, POC-3 §D.3's client API throughout (POC-1's russh-client auth leg, POC-3 §D.3's
client-side request mechanics — `Channel::exec()`, the `wait()`-drain client-side request mechanics (`Channel::exec()`, the `wait()`-drain
trap, fire-and-forget `window_change`/`signal` before `into_stream()`). trap, fire-and-forget `window_change`/`signal` before `into_stream()`)).
Two client-side facts phase 1 designs from: Two client-side facts phase 1 designs from:
1. **The acceptor rule.** The only server-initiated channel type in the 1. **The acceptor rule.** The only server-initiated channel type in the
pattern is `forwarded-tcpip`, and russh's client default for an pattern is `forwarded-tcpip`, and russh's client default for an
unhandled forwarded open is *accept-and-drop* — which is not the unhandled forwarded open is *accept-and-drop*, which is not the
family fail-closed posture. The client-side accepted-open family fail-closed posture. The client-side accepted-open
disposition (accept into a registered handler vs decline) is disposition (accept into a registered handler vs decline) is
alkssh's contract to define. alkssh's contract to define.
@@ -179,9 +179,9 @@ Two client-side facts phase 1 designs from:
## Sequencing and gates ## Sequencing and gates
1. POC-1 first — everything downstream presumes its auth plumbing. 1. POC-1 first; everything downstream presumes its auth plumbing.
2. POC-2 after POC-1 (reuses POC-1's server loopback). 2. POC-2 after POC-1 (reuses POC-1's server loopback).
3. POC-3 last; stage A gates phase 1's mapping ADR work — it is the 3. POC-3 last; stage A gates phase 1's mapping ADR work; it is the
path alkforge phase 0 consumes first. path alkforge phase 0 consumes first.
4. Phase 0 completed when the findings docs existed and the OQ list in 4. Phase 0 completed when the findings docs existed and the OQ list in
`vision.md` §scope questions was either answered by findings or `vision.md` §scope questions was either answered by findings or
@@ -191,12 +191,12 @@ Two client-side facts phase 1 designs from:
verdicts; scope question 4 (auth dispositions + client-side vault verdicts; scope question 4 (auth dispositions + client-side vault
keys) is answered per POC-1/POC-2; scope question 2 (russh pin keys) is answered per POC-1/POC-2; scope question 2 (russh pin
facts) is verified; questions 1 (the `alk/ssh` topology) and 3 facts) is verified; questions 1 (the `alk/ssh` topology) and 3
(feature naming) are decision territory carried as phase 1 OQs — (feature naming) are decision territory carried as phase 1 OQs, and
the seeded OQ list is in each findings doc. the seeded OQ list is in each findings doc.
## References ## References
- `vision.md` (principles the POCs must honor — including that POC - `vision.md` (principles the POCs must honor, including that POC
code carries relaxed constraints but never merges) code carries relaxed constraints but never merges)
- `russh.md` (API shapes the POCs exercise), `auth-bridge.md`, - `russh.md` (API shapes the POCs exercise), `auth-bridge.md`,
`mapping-table.md` `mapping-table.md`
+20 -20
View File
@@ -1,10 +1,10 @@
# Reverse runner — client-direction pattern extraction (alkminer) # Reverse runner: client-direction pattern extraction (alkminer)
Status: phase 0 post-POC extraction. **Reading aid only — concepts and Status: phase 0 post-POC extraction. **Reading aid only: concepts and
wire shapes, never code** (the source is not open-sourced and stays wire shapes, never code** (the source is not open-sourced and stays
that way; per repo license hygiene, reference code is for reading and that way; per repo license hygiene, reference code is for reading and
facts). Source: facts). Source:
`/workspace/@alkminer/alkminer` — specifically `/workspace/@alkminer/alkminer`, specifically
`crates/alkminer-host/src/remote/{ssh,exec,forward,manager,sftp}.rs` `crates/alkminer-host/src/remote/{ssh,exec,forward,manager,sftp}.rs`
and `src/oai/mod.rs`. Also old (russh 0.62 era) and POC-grade; its and `src/oai/mod.rs`. Also old (russh 0.62 era) and POC-grade; its
weak spots are as instructive as its strengths, and both are recorded weak spots are as instructive as its strengths, and both are recorded
@@ -14,19 +14,19 @@ below as facts to design from, not precedents to copy.
The reverse-runner pattern inverts the typical runner flow: the The reverse-runner pattern inverts the typical runner flow: the
**hub/coordinator is the SSH client** and dials out to vanilla `sshd` **hub/coordinator is the SSH client** and dials out to vanilla `sshd`
on rented containers (vast.ai, runpod — any box that exposes SSH). The on rented containers (vast.ai, runpod, any box that exposes SSH). The
runner exposes nothing but the wire; the hub is the active party and runner exposes nothing but the wire; the hub is the active party and
pushes all work and all channel opens. pushes all work and all channel opens.
Security shape (why this beat the alternatives in practice): Security shape (why this beat the alternatives in practice):
- **No inbound surface on runners** — only `sshd` with one key. - **No inbound surface on runners**, only `sshd` with one key.
- **Secrets never leave the hub** — the pattern's killer use was an - **Secrets never leave the hub**; the pattern's killer use was an
OAI-compatible proxy served *by the hub* (`oai/mod.rs`, alkminer OAI-compatible proxy served *by the hub* (`oai/mod.rs`, alkminer
ADR-016): the instance points its LLM tooling at a localhost URL ADR-016): the instance points its LLM tooling at a localhost URL
that rides the tunnel to the hub's proxy, which injects the real that rides the tunnel to the hub's proxy, which injects the real
provider key. The runner authenticates to the proxy with a scoped provider key. The runner authenticates to the proxy with a scoped
dev token — least privilege, no upstream-secret custody. dev token: least privilege, no upstream-secret custody.
- **Reachback both ways, deliberately**: hub→runner services via `-L`, - **Reachback both ways, deliberately**: hub→runner services via `-L`,
runner→hub services via `-R` (the two legs below were both used, runner→hub services via `-R` (the two legs below were both used,
sometimes chained: instance port ‑L→ hub port ‑L→ dev workstation). sometimes chained: instance port ‑L→ hub port ‑L→ dev workstation).
@@ -39,9 +39,9 @@ One authenticated SSH connection per instance carried:
|---|---|---| |---|---|---|
| blocking `exec` | provisioning commands, health checks | session channel open + `exec()`; alktty pipe-mode seam when the runner is family-built | | blocking `exec` | provisioning commands, health checks | session channel open + `exec()`; alktty pipe-mode seam when the runner is family-built |
| background jobs (`start_job`/`job_status`/`job_logs`/`stop_job`) | mining worker lifecycle; pid files on the runner, job rows in hub-side storage | assembly-level job supervision over exec channels; the alkminer pid-file approach is the ad-hoc analog of POC-3 §D's pipe-mode signal gap (alktty exit-chunk signal identity OQ) | | background jobs (`start_job`/`job_status`/`job_logs`/`stop_job`) | mining worker lifecycle; pid files on the runner, job rows in hub-side storage | assembly-level job supervision over exec channels; the alkminer pid-file approach is the ad-hoc analog of POC-3 §D's pipe-mode signal gap (alktty exit-chunk signal identity OQ) |
| SFTP | binary deploy to the instance | **future alkfs client half** — real consumer evidence that the `sftp` feature's deferred implementation needs both directions (alkforge CI artifact deploy is the consumer) | | SFTP | binary deploy to the instance | **future alkfs client half**: real consumer evidence that the `sftp` feature's deferred implementation needs both directions (alkforge CI artifact deploy is the consumer) |
| `-L` client-side tunnel | opencode dev tunnel (instance:4096 → hub) | client opens `direct-tcpip` per accepted local connection and pumps (POC-1's `into_stream` plumbing, reversed) | | `-L` client-side tunnel | opencode dev tunnel (instance:4096 → hub) | client opens `direct-tcpip` per accepted local connection and pumps (POC-1's `into_stream` plumbing, reversed) |
| `-R` client-side tunnel | hub-service reach tunnel (instance → hub's HTTP/OAI proxy) | `tcpip_forward` global request (returns the bound port — no mpsc sink param, matching `russh.md`'s client inventory) then **accept the server-initiated `forwarded-tcpip` opens** and relay each to the hub-local target | | `-R` client-side tunnel | hub-service reach tunnel (instance → hub's HTTP/OAI proxy) | `tcpip_forward` global request (returns the bound port; no mpsc sink param, matching `russh.md`'s client inventory) then **accept the server-initiated `forwarded-tcpip` opens** and relay each to the hub-local target |
## Client-direction facts that phase 1 designs from ## Client-direction facts that phase 1 designs from
@@ -49,7 +49,7 @@ One authenticated SSH connection per instance carried:
client handler receives `server_channel_open_forwarded_tcpip` for client handler receives `server_channel_open_forwarded_tcpip` for
`-R` data. In alkminer's 0.62-era code (and unchanged in kind at `-R` data. In alkminer's 0.62-era code (and unchanged in kind at
0.64.1), an accepted forwarded channel with no handler installed is 0.64.1), an accepted forwarded channel with no handler installed is
**accept-and-drop** — a fail-open-ish default. alkssh's client **accept-and-drop**, a fail-open-ish default. alkssh's client
contract: only channel types/features the assembly registered are contract: only channel types/features the assembly registered are
served; otherwise decline cleanly. This is the client side of the served; otherwise decline cleanly. This is the client side of the
feature-graph symmetry rule; unlike the server auth defaults (which feature-graph symmetry rule; unlike the server auth defaults (which
@@ -60,11 +60,11 @@ One authenticated SSH connection per instance carried:
(`install_forward_sink`) set after `connect` by the consumer that (`install_forward_sink`) set after `connect` by the consumer that
owns the forward. The family translation is an assembly-injected owns the forward. The family translation is an assembly-injected
acceptor-handlers registry (per channel type / per feature), not a acceptor-handlers registry (per channel type / per feature), not a
static config — but the *late-install* dynamic is real: consumers static config, but the *late-install* dynamic is real: consumers
turn capabilities on/off over a live connection. turn capabilities on/off over a live connection.
3. **Keepalives are load-bearing on cloud runners.** The hub needed 3. **Keepalives are load-bearing on cloud runners.** The hub needed
`keepalive_interval` 15 s + `keepalive_max` 3 (OpenSSH `keepalive_interval` 15 s + `keepalive_max` 3 (OpenSSH
`ServerAliveInterval=15`/`ServerAliveCountMax=3` parity) — with `ServerAliveInterval=15`/`ServerAliveCountMax=3` parity). With
russh's default (no keepalives), dead NAT'd connections sat russh's default (no keepalives), dead NAT'd connections sat
unnoticed until a health-check exec failed. Evidence for the unnoticed until a health-check exec failed. Evidence for the
client-side bounded-resources defaults (the server side has client-side bounded-resources defaults (the server side has
@@ -72,28 +72,28 @@ One authenticated SSH connection per instance carried:
4. **Reconnect-and-restore semantics.** Tunnel ports were persisted 4. **Reconnect-and-restore semantics.** Tunnel ports were persisted
(hub storage) and re-bound identically on reconnect; forwards were (hub storage) and re-bound identically on reconnect; forwards were
tracked per connection with idempotent stop handles. This is tracked per connection with idempotent stop handles. This is
assembly policy (session/forward lifecycle), not wire contract — assembly policy (session/forward lifecycle), not wire contract;
kept in view so phase 1 doesn't accidentally bake reconnect policy kept in view so phase 1 doesn't accidentally bake reconnect policy
into the crate. into the crate.
5. **Host-key acceptance for ephemeral fleets — the pattern the fleet 5. **Host-key acceptance for ephemeral fleets: the pattern the fleet
model demands.** alkminer's `check_server_key` returned accept-all model demands.** alkminer's `check_server_key` returned accept-all
for rental fleets. That was the correct pattern for that problem, for rental fleets. That was the correct pattern for that problem,
not a lapse: a fresh container per rent mints a fresh host key, so not a lapse: a fresh container per rent mints a fresh host key, so
no pin can exist beforehand — strict TOFU/"pin everything" is no pin can exist beforehand, so strict TOFU/"pin everything" is
unusable there, and the churn is exactly why accept-all won. The unusable there, and the churn is exactly why accept-all won. The
security anchor is elsewhere: hub-held per-instance dial keys make security anchor is elsewhere: hub-held per-instance dial keys make
a captured hub credential worthless, plus what rides the tunnels. a captured hub credential worthless, plus what rides the tunnels.
The family rule is therefore not "pin everything" — it is "no The family rule is therefore not "pin everything"; it is "no
trust decisions inside the crate": default fail-closed at the trust decisions inside the crate": default fail-closed at the
crate, with an assembly-provided policy seam so each fleet model crate, with an assembly-provided policy seam so each fleet model
gets the control that fits it — host pinning where anchors can gets the control that fits it: host pinning where anchors can
exist (family-built runners, whose host key the assembly mints and exist (family-built runners, whose host key the assembly mints and
therefore knows beforehand), permissive accept where they can't therefore knows beforehand), permissive accept where they can't
(rental fleets). The fleet churn is the reason the two-case policy (rental fleets). The fleet churn is the reason the two-case policy
seam (OQ-05) is the correct shape rather than "pin everything". seam (OQ-05) is the correct shape rather than "pin everything".
6. **Per-runner key registration.** Keys were minted per instance and 6. **Per-runner key registration.** Keys were minted per instance and
registered hub-side (their provisioning flow, including a real registered hub-side (their provisioning flow, including a real
gitea client for dev workspaces) — the deployment precedent for gitea client for dev workspaces), is the deployment precedent for
alkforge's per-runner `IdentityProvider` rows. alkforge's per-runner `IdentityProvider` rows.
## Not carried ## Not carried
@@ -103,7 +103,7 @@ One authenticated SSH connection per instance carried:
files on the runner) belongs to the assembly layer in a family files on the runner) belongs to the assembly layer in a family
deployment; the crate's surface is channels and tunnels. deployment; the crate's surface is channels and tunnels.
- No per-target scoping of proxied traffic existed (dev token proved - No per-target scoping of proxied traffic existed (dev token proved
"a valid dev instance", nothing more) — a real least-privilege gap "a valid dev instance", nothing more), a real least-privilege gap
the family model fixes via per-identity ACLs on the service side. the family model fixes via per-identity ACLs on the service side.
## References ## References
@@ -115,4 +115,4 @@ One authenticated SSH connection per instance carried:
`poc-3-mapping-bakeoff.md` §D.3 (client-request mechanics) `poc-3-mapping-bakeoff.md` §D.3 (client-request mechanics)
- `auth-bridge.md` §Host keys and `check_server_key` (the trust-policy - `auth-bridge.md` §Host keys and `check_server_key` (the trust-policy
seam this doc's ephemeral-fleet acceptance pattern motivates) seam this doc's ephemeral-fleet acceptance pattern motivates)
- alkminer ADR-016 (the OAI proxy pattern — secrets stay at the hub) - alkminer ADR-016 (the OAI proxy pattern: secrets stay at the hub)
+35 -35
View File
@@ -1,4 +1,4 @@
# russh — crate inventory and pin considerations # russh: crate inventory and pin considerations
Status: Draft (phase 0 research). Facts from docs.rs and the GitHub Status: Draft (phase 0 research). Facts from docs.rs and the GitHub
release notes as of 2026-10-25; versions re-verified at architecture release notes as of 2026-10-25; versions re-verified at architecture
@@ -6,7 +6,7 @@ time before any pin is recorded.
## Version landscape ## Version landscape
**Re-verified at architecture time (2026-10-26) — see the section at **Re-verified at architecture time (2026-10-26); see the section at
the end of this file.** russh **0.65.0** was published 2026-10-25 the end of this file.** russh **0.65.0** was published 2026-10-25
(after the POCs verified 0.64.1 as newest); the pin ADR is written (after the POCs verified 0.64.1 as newest); the pin ADR is written
against 0.65.0. The POC wire-behavior evidence transfers: the 0.65.0 against 0.65.0. The POC wire-behavior evidence transfers: the 0.65.0
@@ -25,22 +25,22 @@ breaking changes are handler-signature shapes, not wire behavior.
GHSA-3hm4-p7h4-rvwp (pre-auth stack exhaustion via nested OpenSSH GHSA-3hm4-p7h4-rvwp (pre-auth stack exhaustion via nested OpenSSH
certificates); the agent/pageant fixes are off our path (agent certificates); the agent/pageant fixes are off our path (agent
forwarding declines, ADR-003). forwarding declines, ADR-003).
- 0.64.1 (2026-10-05) was the POC pin — security-fix-only patch of - 0.64.1 (2026-10-05) was the POC pin, a security-fix-only patch of
0.64.0 (GHSA-4wc5-f2rc-q74m — unbounded memory allocation in the 0.64.0 (GHSA-4wc5-f2rc-q74m, unbounded memory allocation in the
agent protocol server; no API change). The POC findings' evidence agent protocol server; no API change). The POC findings' evidence
and this file's earlier inventory facts were verified against and this file's earlier inventory facts were verified against
0.64.1 and carry to 0.65.0 as noted above. 0.64.1 and carry to 0.65.0 as noted above.
- 0.64.0 (2026-10-04) broke API: dropped the - 0.64.0 (2026-10-04) broke API: dropped the
`legacy-ed25519-pkcs8-parser` feature (pre-0.43 non-compliant `legacy-ed25519-pkcs8-parser` feature (pre-0.43 non-compliant
ed25519 keys now hard-error); fixed GHSA-j7h2-66j4-ghjf (server ed25519 keys now hard-error); fixed GHSA-j7h2-66j4-ghjf (server
pubkey auth accepted mismatched signature algorithms — e.g. pubkey auth accepted mismatched signature algorithms, e.g.
rsa-sha2-512 silently downgraded to rsa-sha1); added rsa-sha2-512 silently downgraded to rsa-sha1); added
`Handle::hostkeys_prove` and `Handle::send_global_request`; fixed `Handle::hostkeys_prove` and `Handle::send_global_request`; fixed
`Limits` not being fully enforced. `Limits` not being fully enforced.
- There are older russh families (0.4x–0.5x) with materially different - There are older russh families (0.4x–0.5x) with materially different
handler shapes in circulation; one rough family-internal integration handler shapes in circulation; one rough family-internal integration
example exists (alknet `main`, pinned 0.49) but it is a reading aid example exists (alknet `main`, pinned 0.49) but it is a reading aid
only — a rough working example, months stale — and is **not decision only: a rough working example, months stale, and **not decision
input** for anything here. The pin is chosen against the current input** for anything here. The pin is chosen against the current
published russh, verified at architecture time. published russh, verified at architecture time.
@@ -52,14 +52,14 @@ breaking changes are handler-signature shapes, not wire behavior.
our license satisfies it); record this in the pin ADR so the audit our license satisfies it); record this in the pin ADR so the audit
trail is explicit. trail is explicit.
- **MSRV 1.89, edition 2024.** Our crate's `rust-version` must be ≥ - **MSRV 1.89, edition 2024.** Our crate's `rust-version` must be ≥
russh's once the dependency lands (currently `1.88` in the manifest russh's once the dependency lands (currently `1.88` in the manifest,
— a pending decision, deferred to the pin ADR). a pending decision, deferred to the pin ADR).
- **Crypto backend: exactly one of `aws-lc-rs` (default) or `ring`.** - **Crypto backend: exactly one of `aws-lc-rs` (default) or `ring`.**
The build errors if zero or both are selected. Feature choice is a The build errors if zero or both are selected. Feature choice is a
phase 1 decision (alktls family precedent may pull one way). phase 1 decision (alktls family precedent may pull one way).
- Other features: `flate2` (compression, default), `rsa` (default; - Other features: `flate2` (compression, default), `rsa` (default;
pulls **RC-pinned** `rsa =0.10.0-rc.18` + `pkcs1 =0.8.0-rc.4`), pulls **RC-pinned** `rsa =0.10.0-rc.18` + `pkcs1 =0.8.0-rc.4`),
`async-trait`, `des`/`dsa` ("Danger: insecure" — never enable), `async-trait`, `des`/`dsa` ("Danger: insecure"; never enable),
`serde`. The `rsa` question interacts with the RC-pin question below. `serde`. The `rsa` question interacts with the RC-pin question below.
- **RC dependencies in the current pin set:** `ssh-key =0.7.0-rc.11` - **RC dependencies in the current pin set:** `ssh-key =0.7.0-rc.11`
(exact-pinned by russh itself via `russh::keys::ssh_key` re-export), (exact-pinned by russh itself via `russh::keys::ssh_key` re-export),
@@ -77,7 +77,7 @@ Required: `type Error: From<russh::Error> + Send`. Everything else is
provided methods; at 0.64.1 the default auth impls are **reject** provided methods; at 0.64.1 the default auth impls are **reject**
(`Auth::reject()`) for `auth_none`, `auth_password`, (`Auth::reject()`) for `auth_none`, `auth_password`,
`auth_keyboard_interactive`, `auth_publickey`, and `auth_keyboard_interactive`, `auth_publickey`, and
`auth_openssh_certificate` — only `auth_publickey_offered` defaults to `auth_openssh_certificate`; only `auth_publickey_offered` defaults to
accept (verified in 0.64.1 source; POC-1 corrected this doc's earlier accept (verified in 0.64.1 source; POC-1 corrected this doc's earlier
accept-defaults assumption). Non-auth defaults decline/no-op. The accept-defaults assumption). Non-auth defaults decline/no-op. The
fail-closed discipline is nonetheless *ours to implement explicitly* fail-closed discipline is nonetheless *ours to implement explicitly*
@@ -86,7 +86,7 @@ on a bump), and POC-1 proves the explicit implementations. Key
inventory: inventory:
- Auth: `auth_none(user)`, `auth_password(user, password)`, - Auth: `auth_none(user)`, `auth_password(user, password)`,
`auth_publickey_offered(user, &PublicKey)` (pre-auth probe — cheap `auth_publickey_offered(user, &PublicKey)` (pre-auth probe, a cheap
rejection hook; key ownership not yet verified), rejection hook; key ownership not yet verified),
`auth_publickey(user, &PublicKey)` (called **after** russh verified `auth_publickey(user, &PublicKey)` (called **after** russh verified
the signature and key ownership), `auth_openssh_certificate(user, the signature and key ownership), `auth_openssh_certificate(user,
@@ -96,7 +96,7 @@ inventory:
`auth_succeeded(&mut Session)` fires on success. `auth_succeeded(&mut Session)` fires on success.
- Channel opens (each receives `channel: Channel<Msg>` + - Channel opens (each receives `channel: Channel<Msg>` +
`reply: ChannelOpenHandle`; **dropping the handle without calling `reply: ChannelOpenHandle`; **dropping the handle without calling
`reply.accept()`/`reply.reject()` auto-rejects the open** — a `reply.accept()`/`reply.reject()` auto-rejects the open**, a
natural fail-closed default): `channel_open_session`, natural fail-closed default): `channel_open_session`,
`channel_open_direct_tcpip(host_to_connect, port_to_connect, `channel_open_direct_tcpip(host_to_connect, port_to_connect,
originator_address, originator_port)`, originator_address, originator_port)`,
@@ -119,12 +119,12 @@ inventory:
`lookup_dh_gex_group`. `lookup_dh_gex_group`.
**Dispatch/runtime facts (POC-3, load-bearing for the adapter):** the **Dispatch/runtime facts (POC-3, load-bearing for the adapter):** the
session's event loop invokes handler callbacks *inline* — a blocking session's event loop invokes handler callbacks *inline*: a blocking
handler suspends all packet routing for the connection (channel data handler suspends all packet routing for the connection (channel data
is only routed by that same loop; an inline serve deadlocks against is only routed by that same loop; an inline serve deadlocks against
a peer's follow-up bytes, e.g. a receive-pack push). Every serving a peer's follow-up bytes, e.g. a receive-pack push). Every serving
path must `tokio::spawn` and return fast. Exit reporting from spawned path must `tokio::spawn` and return fast. Exit reporting from spawned
tasks rides `Session::handle()` — a cloneable server-side `Handle` tasks rides `Session::handle()`, a cloneable server-side `Handle`
with `data` / `extended_data(channel, ext, data)` / `eof` / `close` / with `data` / `extended_data(channel, ext, data)` / `eof` / `close` /
`exit_status_request` / `exit_signal_request` (send `exit-status` `exit_status_request` / `exit_signal_request` (send `exit-status`
before `close`). Client-side: `Channel::exec()` (no `request_exec`), before `close`). Client-side: `Channel::exec()` (no `request_exec`),
@@ -138,14 +138,14 @@ explicitly. Details and wire evidence:
### `server::Config` knobs (bounded-resources posture) ### `server::Config` knobs (bounded-resources posture)
`server_id`, `methods: MethodSet` (advertised auth methods — honest `server_id`, `methods: MethodSet` (advertised auth methods; honest
advertisement means this must mirror the feature graph), `keys: Vec<PrivateKey>` (host keys), `certificates: Vec<Certificate>`, advertisement means this must mirror the feature graph), `keys: Vec<PrivateKey>` (host keys), `certificates: Vec<Certificate>`,
`auth_rejection_time` + `auth_rejection_time_initial`, `auth_rejection_time` + `auth_rejection_time_initial`,
`max_auth_attempts`, `limits: Limits` (bytes/time before rekey), `max_auth_attempts`, `limits: Limits` (bytes/time before rekey),
`window_size`, `maximum_packet_size`, `channel_buffer_size`, `window_size`, `maximum_packet_size`, `channel_buffer_size`,
`event_buffer_size`, `inactivity_timeout`, `keepalive_interval`, `event_buffer_size`, `inactivity_timeout`, `keepalive_interval`,
`keepalive_max`, `nodelay`, `preferred: Preferred` (algorithm `keepalive_max`, `nodelay`, `preferred: Preferred` (algorithm
preferences — the place any algorithm restriction would be enforced). preferences, the place any algorithm restriction would be enforced).
Most family bounded-resource conventions map directly: max concurrent Most family bounded-resource conventions map directly: max concurrent
channels is additionally ours (alkcall-side per-identity policy when channels is additionally ours (alkcall-side per-identity policy when
@@ -158,14 +158,14 @@ bridged; in-crate when serving locally), session timeout =
Same shape as server: `type Error: From<russh::Error> + Send + Debug`; Same shape as server: `type Error: From<russh::Error> + Send + Debug`;
provided methods default sensibly, but **`check_server_key` defaults provided methods default sensibly, but **`check_server_key` defaults
to reject-all and must be overridden** — host-key policy is a phase 1 to reject-all and must be overridden**; host-key policy is a phase 1
design point (for the family client: pin the expected server design point (for the family client: pin the expected server
fingerprint at the assembly layer, per the no-trust-on-first-use fingerprint at the assembly layer, per the no-trust-on-first-use
direction this implies). Inventory: `check_server_key` direction this implies). Inventory: `check_server_key`
(&PublicKeyOrCertificate) → bool; `kex_done`; the full (&PublicKeyOrCertificate) → bool; `kex_done`; the full
server-initiated channel-open set (`server_channel_open_session / server-initiated channel-open set (`server_channel_open_session /
_direct_tcpip / _forwarded_tcpip / _forwarded_streamlocal / _x11 / _direct_tcpip / _forwarded_tcpip / _forwarded_streamlocal / _x11 /
_agent_forward / _unknown` — the client is an acceptor for forwarded _agent_forward / _unknown`; the client is an acceptor for forwarded
channels, i.e. the `-R` data path) with the same channels, i.e. the `-R` data path) with the same
`ChannelOpenHandle` accept/reject semantics; `should_accept_unknown_server_channel`; data plane (`data`, `ChannelOpenHandle` accept/reject semantics; `should_accept_unknown_server_channel`; data plane (`data`,
`extended_data`, `exit_status`, `exit_signal`, `xon_xoff`, `extended_data`, `exit_status`, `exit_signal`, `xon_xoff`,
@@ -180,30 +180,30 @@ PrivateKeyWithHashAlg)`, `authenticate_password`,
`authenticate_none`, `authenticate_keyboard_interactive_start/ `authenticate_none`, `authenticate_keyboard_interactive_start/
respond`, `authenticate_openssh_cert`, the `*_with` signer-injected respond`, `authenticate_openssh_cert`, the `*_with` signer-injected
variants (`authenticate_publickey_with(user, PublicKey, hash_alg, variants (`authenticate_publickey_with(user, PublicKey, hash_alg,
&mut Signer)` — a signer-injection seam; under alkssh's at-rest &mut Signer)`, a signer-injection seam; under alkssh's at-rest
storage model (held keys are bearer-token-class secrets decrypted at storage model (held keys are bearer-token-class secrets decrypted at
use — see `auth-bridge.md`) it is not presumed to be the vault use; see `auth-bridge.md`) it is not presumed to be the vault
integration shape, but it exists and POC-2 evaluates it against the integration shape, but it exists and POC-2 evaluates it against the
simple decrypt-and-decode flow; POC-2 verdict: dismissed — no simple decrypt-and-decode flow; POC-2 verdict: dismissed, since no
provided `impl Signer` exists anywhere in 0.64.1 (the doc's "SSH provided `impl Signer` exists anywhere in 0.64.1 (the doc's "SSH
agent" implementation claim is stale), a custom signer must agent" implementation claim is stale), a custom signer must
replicate the splice convention (`to_sign || string{sig}`), and an replicate the splice convention (`to_sign || string{sig}`), and an
in-process vault gains nothing over one-time decode at dial — in-process vault gains nothing over one-time decode at dial; see
see `poc-2-vault-keys.md` §4), `authenticate_gssapi_with_mic`, `poc-2-vault-keys.md` §4), `authenticate_gssapi_with_mic`,
`best_supported_rsa_hash` (waits ≤1s for EXT_INFO `server-sig-algs`). `best_supported_rsa_hash` (waits ≤1s for EXT_INFO `server-sig-algs`).
Channel opening: only the four typed opens exist — Channel opening: only the four typed opens exist:
`channel_open_session`, `channel_open_x11`, `channel_open_session`, `channel_open_x11`,
`channel_open_direct_tcpip(host, port, originator...)`, `channel_open_direct_tcpip(host, port, originator...)`,
`channel_open_direct_streamlocal(socket_path)` — no generic `channel_open_direct_streamlocal(socket_path)`; there is no generic
`channel_open(name)`. On a server **reject**, the typed open call `channel_open(name)`. On a server **reject**, the typed open call
itself resolves to `Err(Error::ChannelOpenFailure(reason))` — no itself resolves to `Err(Error::ChannelOpenFailure(reason))`, and no
`wait()`-and-match pattern needed (POC-1 verified with vanilla `wait()`-and-match pattern needed (POC-1 verified with vanilla
declines); the session stays usable after declines. Channels expose declines); the session stays usable after declines. Channels expose
`wait() -> Option<ChannelMsg>`, `wait() -> Option<ChannelMsg>`,
`split()`, `into_stream() -> ChannelStream` (AsyncRead + AsyncWrite), `split()`, `into_stream() -> ChannelStream` (AsyncRead + AsyncWrite),
`make_reader()/make_reader_ext(ext)/make_writer()/make_writer_ext()` `make_reader()/make_reader_ext(ext)/make_writer()/make_writer_ext()`
(tokio duplex adapters — **the bridge primitives for every mapping**), (tokio duplex adapters, **the bridge primitives for every mapping**),
`request_pty/request_shell/exec/request_subsystem/set_env/ `request_pty/request_shell/exec/request_subsystem/set_env/
window_change/signal/eof/close/exit_status`, window_change/signal/eof/close/exit_status`,
`writable_packet_size()`. `writable_packet_size()`.
@@ -226,7 +226,7 @@ plus `PrivateKey`, `PublicKey`, `Certificate`, `HashAlg`,
(`load_secret_key`, `decode_secret_key`, `load_public_key`, (`load_secret_key`, `decode_secret_key`, `load_public_key`,
`parse_public_key_base64`, `load_openssh_certificate`, `parse_public_key_base64`, `load_openssh_certificate`,
`decode_openssh`). Fingerprint computation lives in `ssh-key` `decode_openssh`). Fingerprint computation lives in `ssh-key`
(`PublicKey::fingerprint`, SHA-256 OpenSSH format `SHA256:base64`) — (`PublicKey::fingerprint`, SHA-256 OpenSSH format `SHA256:base64`),
relevant to `auth-bridge.md`. `known_hosts::check_known_hosts[_path]` relevant to `auth-bridge.md`. `known_hosts::check_known_hosts[_path]`
exists for host-key verification. OpenSSH agent interop (`keys::agent`) exists for host-key verification. OpenSSH agent interop (`keys::agent`)
exists but is out of family scope (agent forwarding declines; see exists but is out of family scope (agent forwarding declines; see
@@ -240,20 +240,20 @@ items below are the questions as phase 0 posed them:
1. Which family/russh line (current stable 0.64.1 is the default 1. Which family/russh line (current stable 0.64.1 is the default
candidate) and whether the RC-pinned `ssh-key`/`rsa`/`pkcs1` tree candidate) and whether the RC-pinned `ssh-key`/`rsa`/`pkcs1` tree
is acceptable or gates the pin. is acceptable or gates the pin.
2. Crypto backend (`aws-lc-rs` vs `ring`) — align with alktls. 2. Crypto backend (`aws-lc-rs` vs `ring`); align with alktls.
3. `rust-version` bump in our manifest (≥ 1.89) — lands with the 3. `rust-version` bump in our manifest (≥ 1.89), landing with the
dependency. dependency.
4. Whether to enable the `rsa` feature at all (family keys are 4. Whether to enable the `rsa` feature at all (family keys are
ed25519-first; disabling `rsa` drops two RC deps from the tree). ed25519-first; disabling `rsa` drops two RC deps from the tree).
5. Confirm compatibility with the vanilla clients alkforge needs (ssh 5. Confirm compatibility with the vanilla clients alkforge needs (ssh
+ git + scp-era behavior) — POC-1's deliverable. + git + scp-era behavior); POC-1's deliverable.
## References ## References
- docs.rs russh 0.64.1: server `Handler`, client `Handler`, `Config`s, - docs.rs russh 0.64.1: server `Handler`, client `Handler`, `Config`s,
`Handle`, `keys` module (sections above quote their inventories) `Handle`, `keys` module (sections above quote their inventories)
- russh GitHub releases 0.64.0 / 0.64.1 (GHSA ids quoted above) - russh GitHub releases 0.64.0 / 0.64.1 (GHSA ids quoted above)
- alknet `main` `crates/alknet-core/src/auth/` — rough reference only, - alknet `main` `crates/alknet-core/src/auth/`: rough reference only,
explicitly not decision input (0.49-era API; verify nothing against explicitly not decision input (0.49-era API; verify nothing against
it) it)
@@ -265,10 +265,10 @@ build (`/tmp/opencode/alkssh-pin-check`, disposable):
| Fact | Verified value | | Fact | Verified value |
|---|---| |---|---|
| Current published | **0.65.0** (2026-10-25) — see Version landscape above | | Current published | **0.65.0** (2026-10-25); see Version landscape above |
| Backend feature | `aws-lc-rs` (dashed feature name; also russh's own default alongside `flate2`, `rsa`) | | Backend feature | `aws-lc-rs` (dashed feature name; also russh's own default alongside `flate2`, `rsa`) |
| Scratch build | `default-features = false, features = ["aws-lc-rs", "rsa"]` — clean, ~50 s, **no external provisioning** (prebuilt aws-lc bindings cover this platform; `aws-lc-rs 1.18.2` / `aws-lc-sys 0.46.0`) | | Scratch build | `default-features = false, features = ["aws-lc-rs", "rsa"]`: clean, ~50 s, **no external provisioning** (prebuilt aws-lc bindings cover this platform; `aws-lc-rs 1.18.2` / `aws-lc-sys 0.46.0`) |
| Tree (the pin set) | `ssh-key =0.7.0-rc.11` (exact), `rsa =0.10.0-rc.18`, `pkcs1 =0.8.0-rc.4`, `getrandom 0.4.3`, `rand_core 0.10.1` — same RC set as the POCs' 0.64.1 tree | | Tree (the pin set) | `ssh-key =0.7.0-rc.11` (exact), `rsa =0.10.0-rc.18`, `pkcs1 =0.8.0-rc.4`, `getrandom 0.4.3`, `rand_core 0.10.1`; same RC set as the POCs' 0.64.1 tree |
| MSRV | 1.89 (both 0.64.1 and 0.65.0) | | MSRV | 1.89 (both 0.64.1 and 0.65.0) |
| License | Apache-2.0 only | | License | Apache-2.0 only |
+20 -21
View File
@@ -6,8 +6,8 @@ Status: Draft (phase 0 research).
alkssh is the alk family's SSH adapter: a thin wrapper around russh alkssh is the alk family's SSH adapter: a thin wrapper around russh
terminating the vanilla-ssh wire and bridging to/from alkcall channels terminating the vanilla-ssh wire and bridging to/from alkcall channels
(family ADR-003). It is the alkhttp-style adapter for the SSH protocol (family ADR-003). It is the alkhttp-style adapter for the SSH protocol, the
— the vanilla-ssh front door for vanilla clients, and the family's vanilla-ssh front door for vanilla clients, and the family's
vanilla-ssh client where a family app dials an upstream SSH server. vanilla-ssh client where a family app dials an upstream SSH server.
The substance almost entirely exists in the family. What alkssh adds is The substance almost entirely exists in the family. What alkssh adds is
@@ -25,7 +25,7 @@ The alkforge-relevant property is **exclusion**: an alkforge ssh
endpoint built with only the `git` feature is structurally incapable of endpoint built with only the `git` feature is structurally incapable of
serving a shell, tunnels, or socks. Every mapping is a cargo feature; serving a shell, tunnels, or socks. Every mapping is a cargo feature;
unfeatured channel opens decline fail-closed (family ADR-003 decision unfeatured channel opens decline fail-closed (family ADR-003 decision
3). This is why alkssh leads alkforge's phase 0 — the adapter is glue, 3). This is why alkssh leads alkforge's phase 0: the adapter is glue,
not protocol invention. not protocol invention.
## Guiding principles ## Guiding principles
@@ -36,10 +36,10 @@ not protocol invention.
party-upstream posture applies (file asks upstream; never silently party-upstream posture applies (file asks upstream; never silently
absorb a workaround). absorb a workaround).
2. **A door in both directions, vanilla on the outside.** alkssh is a 2. **A door in both directions, vanilla on the outside.** alkssh is a
door for vanilla clients (server direction: vanilla in, family out door for vanilla clients (server direction: vanilla in, family
— each channel served by a family seam entry; primary current out, each channel served by a family seam entry; primary current
purpose: git) and a door to vanilla servers (client direction: purpose: git) and a door to vanilla servers (client direction:
family in, vanilla out — the reverse-runner shape: a family hub family in, vanilla out; the reverse-runner shape: a family hub
dials vanilla sshd and the ops map family-side onto the same dials vanilla sshd and the ops map family-side onto the same
protocols, **alktty** for sessions, **alktunnels** for protocols, **alktty** for sessions, **alktunnels** for
tunnels/-D legs, **alkgit** for upstream git, alkfs client later tunnels/-D legs, **alkgit** for upstream git, alkfs client later
@@ -64,19 +64,19 @@ not protocol invention.
conventions, and password auth are not the alk family's identity conventions, and password auth are not the alk family's identity
model. A verified key's fingerprint → `Identity` via alkcall's model. A verified key's fingerprint → `Identity` via alkcall's
`IdentityProvider` is the only resolution path; secrets an alkssh `IdentityProvider` is the only resolution path; secrets an alkssh
*client* holds come from alkvault-backed sources — vault-encrypted *client* holds come from alkvault-backed sources: vault-encrypted
at rest, treated as bearer-token-class secret material — never env at rest, treated as bearer-token-class secret material, never env
vars (family ADR-002 decision 5). The vault is a secret store on vars (family ADR-002 decision 5). The vault is a secret store on
this path, not a key generator or key custodian. this path, not a key generator or key custodian.
## Non-goals ## Non-goals
- **Authoring SSH protocol machinery** — no in-family framing, cipher, - **Authoring SSH protocol machinery**: no in-family framing, cipher,
or KEX code, ever (russh upstream decides what SSH is). or KEX code, ever (russh upstream decides what SSH is).
- **Being an sshd.** No local user accounts, no `authorized_keys` file - **Being an sshd.** No local user accounts, no `authorized_keys` file
semantics of the OpenSSH variety, no PAM-shaped anything. Deployment semantics of the OpenSSH variety, no PAM-shaped anything. Deployment
posture belongs to the embedding app. posture belongs to the embedding app.
- **Serving sftp** — deferred to alkfs (family ADR-002); the subsystem - **Serving sftp**: deferred to alkfs (family ADR-002); the subsystem
decline is the placeholder contract. decline is the placeholder contract.
- **A binary / CLI.** Downstream apps embed the crate; there is no - **A binary / CLI.** Downstream apps embed the crate; there is no
`alkssh` executable in this crate. `alkssh` executable in this crate.
@@ -90,26 +90,25 @@ not protocol invention.
These frame the research docs; each is a candidate open question for These frame the research docs; each is a candidate open question for
phase 1's `open-questions.md`: phase 1's `open-questions.md`:
1. **The `alk/ssh` ALPN's topology role** — alkcall ADR-004 reserves 1. **The `alk/ssh` ALPN's topology role**: alkcall ADR-004 reserves
`alk/ssh` → `SshAdapter`, ADR-034's channels table gives it `alk/ssh` → `SshAdapter`, ADR-034's channels table gives it
stream_types `[0, 1]` ("SSH multiplexes internally, including its stream_types `[0, 1]` ("SSH multiplexes internally, including its
own control"), and ADR-009 sketches a browser-WASM route where the own control"), and ADR-009 sketches a browser-WASM route where the
ALPN transports an SSH stream to an `SshAdapter`. But family ADR-003 ALPN transports an SSH stream to an `SshAdapter`. But family ADR-003
describes alkssh terminating vanilla SSH at an edge. Whether describes alkssh terminating vanilla SSH at an edge. It remains
`alk/ssh` carries raw vanilla-SSH bytes between alkssh instances unresolved whether `alk/ssh` carries raw vanilla-SSH bytes between
(a relay leg), serves the browser route, or both — and how that alkssh instances (a relay leg), serves the browser route, or both,
interacts with ADR-042's "the hub never runs a handler for and how that interacts with ADR-042's "the hub never runs a handler
`alk/ssh`" relay ruling — is unresolved (see `mapping-table.md` for `alk/ssh`" relay ruling (see `mapping-table.md` §topology).
§topology). 2. **russh pin**: version, crypto-backend feature, and the
2. **russh pin** — version, crypto-backend feature, and the
RC-pinned transitive dependency situation (see `russh.md`). RC-pinned transitive dependency situation (see `russh.md`).
3. **Feature names + graph** — the `session`/`tunnel`/`dynamic-forward`/`git`/`sftp` naming and their independence 3. **Feature names + graph**: the `session`/`tunnel`/`dynamic-forward`/`git`/`sftp` naming and their independence
(no feature implies another; flat until architecture says otherwise). (no feature implies another; flat until architecture says otherwise).
4. **Auth dispositions** — which russh auth methods the server 4. **Auth dispositions**: which russh auth methods the server
implements vs declines; the exact `auth_publickey` → fingerprint → implements vs declines; the exact `auth_publickey` → fingerprint →
identity contract; client-side vault-backed at-rest key handling identity contract; client-side vault-backed at-rest key handling
(see `auth-bridge.md`). (see `auth-bridge.md`).
5. **The mapping table details** — per channel type and per request, 5. **The mapping table details**: per channel type and per request,
the family seam, the parameter mapping, and the decline contract the family seam, the parameter mapping, and the decline contract
(see `mapping-table.md`); POC validation per `pocs.md`. (see `mapping-table.md`); POC validation per `pocs.md`.
+43 -42
View File
@@ -18,7 +18,7 @@ leverages:
4. **Task-Driven**: Structured task graphs with dependency analysis 4. **Task-Driven**: Structured task graphs with dependency analysis
5. **Safe Exit**: Always have a way to unblock progress when stuck 5. **Safe Exit**: Always have a way to unblock progress when stuck
6. **Categorical Estimates**: Use risk/scope/impact categories, not time 6. **Categorical Estimates**: Use risk/scope/impact categories, not time
estimates. These are structurally important — upstream failures multiply estimates. These are structurally important: upstream failures multiply
downstream damage regardless of developer type (human or LLM). See the downstream damage regardless of developer type (human or LLM). See the
cost-benefit framework in taskgraph's framework docs. cost-benefit framework in taskgraph's framework docs.
@@ -37,7 +37,7 @@ problems need investigation.
4. Document learnings 4. Document learnings
5. Converge on recommended approach 5. Converge on recommended approach
**POC placement (two modes)** — POC code never merges to main; the **POC placement (two modes)**: POC code never merges to main; the
deliverable is the findings write-up: deliverable is the findings write-up:
- **Branch mode** (POC builds on code in this repo): git branch off main, - **Branch mode** (POC builds on code in this repo): git branch off main,
@@ -56,11 +56,11 @@ approaches
**Process**: **Process**:
1. Architect creates the architecture documentation structure: 1. Architect creates the architecture documentation structure:
- `docs/architecture/README.md` — index with doc table, ADR table, lifecycle - `docs/architecture/README.md`: index with doc table, ADR table, lifecycle
- `docs/architecture/overview.md` — package purpose, exports, dependencies - `docs/architecture/overview.md`: package purpose, exports, dependencies
- `docs/architecture/<component>.md` — one focused doc per component/area - `docs/architecture/<component>.md`: one focused doc per component/area
- `docs/architecture/decisions/` — numbered ADR files (ADR-001, ADR-002, ...) - `docs/architecture/decisions/`: numbered ADR files (ADR-001, ADR-002, ...)
- `docs/architecture/open-questions.md` — centralized tracker with OQ-IDs - `docs/architecture/open-questions.md`: centralized tracker with OQ-IDs
2. All docs start in `Draft` status. Spec docs reference ADRs by number (not 2. All docs start in `Draft` status. Spec docs reference ADRs by number (not
inline rationale) and OQs by number (not inline questions). inline rationale) and OQs by number (not inline questions).
3. Architecture Review validates for ambiguities, risks, and structural issues 3. Architecture Review validates for ambiguities, risks, and structural issues
@@ -146,16 +146,16 @@ WHAT, ADRs explain WHY, open questions track what's unresolved.
- Extract decisions into numbered ADRs in `decisions/` directory - Extract decisions into numbered ADRs in `decisions/` directory
- Centralize open questions in `open-questions.md` with OQ-IDs - Centralize open questions in `open-questions.md` with OQ-IDs
- Maintain `README.md` as the architecture index - Maintain `README.md` as the architecture index
- Keep spec documents focused (~500 lines) — reference ADRs and OQs, don't inline them - Keep spec documents focused (about 500 lines); reference ADRs and OQs, don't inline them
- Redirect exploration work to Research Specialist - Redirect exploration work to Research Specialist
- Iterate based on review feedback - Iterate based on review feedback
**Deliverables**: **Deliverables**:
- `docs/architecture/README.md` — index with doc table, ADR table, lifecycle - `docs/architecture/README.md`: index with doc table, ADR table, lifecycle
- `docs/architecture/<component>.md` — focused spec documents - `docs/architecture/<component>.md`: focused spec documents
- `docs/architecture/decisions/` — numbered ADR files - `docs/architecture/decisions/`: numbered ADR files
- `docs/architecture/open-questions.md` — centralized OQ tracker - `docs/architecture/open-questions.md`: centralized OQ tracker
--- ---
@@ -191,17 +191,17 @@ sessions.
**Mode**: Primary (manages worktrees and agent sessions) **Mode**: Primary (manages worktrees and agent sessions)
**Uses**: The `worktree` tool from the **open-coordinator** opencode plugin. **Uses**: The `worktree` tool from the **open-coordinator** opencode plugin.
Single tool with `{action, args}` dispatch. Role is auto-detected — coordinator Single tool with `{action, args}` dispatch. Role is auto-detected: coordinator
sessions get the full operation set, spawned implementation sessions get a sessions get the full operation set, spawned implementation sessions get a
limited set (current, notify, status). No mode toggle required. limited set (current, notify, status). No mode toggle required.
**Tools**: **Tools**:
- `worktree({action, args})` — spawn, sessions, dashboard, message, abort, - `worktree({action, args})`: spawn, sessions, dashboard, message, abort,
cleanup cleanup
- Bash (opencode CLI for session interaction) - Bash (opencode CLI for session interaction)
- Read (monitor task files) - Read (monitor task files)
- `memory` / `memory_compact` — context management and session history (via - `memory` / `memory_compact`: context management and session history (via
@alkdev/open-memory, when available) @alkdev/open-memory, when available)
**Key Behaviors**: **Key Behaviors**:
@@ -230,10 +230,10 @@ limited set (current, notify, status). No mode toggle required.
**Tools**: **Tools**:
- Read, Write, Edit, Glob, Grep, Bash - Read, Write, Edit, Glob, Grep, Bash
- `worktree({action: "notify", ...})` — report progress/blockers to coordinator - `worktree({action: "notify", ...})`: report progress/blockers to coordinator
- `worktree({action: "current"})` — verify worktree assignment - `worktree({action: "current"})`: verify worktree assignment
- webSearch (documentation lookup) - webSearch (documentation lookup)
- `memory` / `memory_compact` — context management (via @alkdev/open-memory, - `memory` / `memory_compact`: context management (via @alkdev/open-memory,
when available) when available)
**Key Behaviors**: **Key Behaviors**:
@@ -340,8 +340,8 @@ mode: scratch project outside the repo)
- Create minimal POCs to validate hypotheses - Create minimal POCs to validate hypotheses
- Branch mode: work in isolated research worktrees; standalone mode: work - Branch mode: work in isolated research worktrees; standalone mode: work
in the scratch project only in the scratch project only
- Document findings and recommendations (findings are the deliverable — - Document findings and recommendations (findings are the deliverable; POC
POC source is disposable and never merges to main) source is disposable and never merges to main)
- Timebox strictly - abandon if taking too long - Timebox strictly - abandon if taking too long
- Be honest about limitations and blockers - Be honest about limitations and blockers
@@ -404,7 +404,7 @@ Implement OAuth2 authentication with provider abstraction.
### Categorical Estimates ### Categorical Estimates
These fields are structurally important, not optional metadata. They power These fields are structurally important, not optional metadata. They power
`taskgraph decompose`, `risk-path`, `critical`, and `bottleneck` — commands that `taskgraph decompose`, `risk-path`, `critical`, and `bottleneck`: commands that
reveal structural problems in the task graph. A task missing `scope`, `risk`, reveal structural problems in the task graph. A task missing `scope`, `risk`,
`impact`, or `level` is a red flag indicating incomplete decomposition. See the `impact`, or `level` is a red flag indicating incomplete decomposition. See the
cost-benefit framework in taskgraph's framework docs for the reasoning. cost-benefit framework in taskgraph's framework docs for the reasoning.
@@ -432,7 +432,7 @@ cost-benefit framework in taskgraph's framework docs for the reasoning.
| phase | A whole phase's milestone or gate slips (e.g. a decomposition OQ)| | phase | A whole phase's milestone or gate slips (e.g. a decomposition OQ)|
| project | The project's contract: wire formats, published API, security | | project | The project's contract: wire formats, published API, security |
**Valid frontmatter enum variants (taskgraph 0.1.3)** — only these **Valid frontmatter enum variants (taskgraph 0.1.3)**: only these
literal values parse; anything else fails `taskgraph validate`: literal values parse; anything else fails `taskgraph validate`:
- `status`: `pending` | `in-progress` | `completed` | `failed` | `blocked` - `status`: `pending` | `in-progress` | `completed` | `failed` | `blocked`
@@ -443,20 +443,21 @@ literal values parse; anything else fails `taskgraph validate`:
Three gotchas worth stating because agents have hit all three: Three gotchas worth stating because agents have hit all three:
1. `status: done` is **not** a value — the lifecycle word is 1. `status: done` is **not** a value; the lifecycle word is
`completed` (and `in-progress`, not `in_progress` or `wip`). `completed` (and `in-progress`, not `in_progress` or `wip`).
2. A file in `tasks/` **without frontmatter is silently skipped** by 2. A file in `tasks/` **without frontmatter is silently skipped** by
`taskgraph validate`, `list`, and every graph command — no error, `taskgraph validate`, `list`, and every graph command. It produces
the file just doesn't exist as far as the graph is concerned. no error; the file just doesn't exist as far as the graph is
concerned.
Every tracker or task file under `tasks/` must carry a complete Every tracker or task file under `tasks/` must carry a complete
frontmatter block (`id`, `name` required; `depends_on` plus the frontmatter block (`id`, `name` required; `depends_on` plus the
five categorical fields expected). If you need a plain prose five categorical fields expected). If you need a plain prose
tracker, keep it under `docs/` instead. tracker, keep it under `docs/` instead.
3. `status` transitions run through the lifecycle only: `pending` → 3. `status` transitions run through the lifecycle only: `pending` →
`in-progress` → `completed` (or `failed`/`blocked`). A finished `in-progress` → `completed` (or `failed`/`blocked`). A finished
task never goes sideways to `done`/`finished` — the enum is closed. task never goes sideways to `done`/`finished`; the enum is closed.
When in doubt, run `taskgraph validate` — it is the gate that CI-style When in doubt, run `taskgraph validate`: it is the gate that CI-style
checks and review passes will hit. checks and review passes will hit.
### Task Lifecycle ### Task Lifecycle
@@ -472,15 +473,15 @@ checks and review passes will hit.
### Architecture work and the `level` field ### Architecture work and the `level` field
Architecture work uses the same taskgraph format as implementation work — Architecture work uses the same taskgraph format as implementation work;
there is no separate `architecture` level. The `level` enum there is no separate `architecture` level. The `level` enum
(`planning, decomposition, implementation, review, research`) already covers (`planning, decomposition, implementation, review, research`) already covers
architecture activities: architecture activities:
| Architecture activity | `level` | Rationale | | Architecture activity | `level` | Rationale |
|---|---|---| |---|---|---|
| Writing an ADR (the decision) | `implementation` | The ADR *is* the deliverable — the implementation of an architecture decision. Calling it `planning` would mislabel the artifact. | | Writing an ADR (the decision) | `implementation` | The ADR *is* the deliverable, namely the implementation of an architecture decision. Calling it `planning` would mislabel the artifact. |
| Decomposing a spec into ADRs + OQs | `decomposition` | Direct fit — breaking a large architecture area into decideable units. | | Decomposing a spec into ADRs + OQs | `decomposition` | Direct fit: breaking a large architecture area into decideable units. |
| Architecture review pass | `review` | Direct fit. | | Architecture review pass | `review` | Direct fit. |
| Research / POC before a decision | `research` | Direct fit. | | Research / POC before a decision | `research` | Direct fit. |
| Spec writing (overview, component spec) | `implementation` | The spec is the deliverable. | | Spec writing (overview, component spec) | `implementation` | The spec is the deliverable. |
@@ -530,8 +531,8 @@ the deferral has two halves that must stay in sync:
2. **The machine-readable half** (in `tasks/architecture/`): an 2. **The machine-readable half** (in `tasks/architecture/`): an
external-trigger tracker task represents the unblocking condition. It is external-trigger tracker task represents the unblocking condition. It is
tagged `[external-trigger, deferred-oq]`, has `risk: trivial` and tagged `[external-trigger, deferred-oq]`, has `risk: trivial` and
`level: research` (it is not actionable work — it tracks whether the `level: research` (it is not actionable work; it tracks whether the
external condition has arrived), and its `id` is referenced from the OQ's external condition has arrived), and its `id` is referenced from the OQ's
`Blocked on:` text. This is the surface for the task graph: `taskgraph` `Blocked on:` text. This is the surface for the task graph: `taskgraph`
tools can reason about it, and downstream work that depends on the tools can reason about it, and downstream work that depends on the
decision can declare `depends_on: [architecture/oq-NN-...]`. decision can declare `depends_on: [architecture/oq-NN-...]`.
@@ -540,7 +541,7 @@ When the unblocking condition arrives (a use case materializes, a crate is
specced), the tracker task is marked `completed` and the OQ transitions from specced), the tracker task is marked `completed` and the OQ transitions from
`deferred(scope)` to `open` (or directly to `resolved` if no architecture `deferred(scope)` to `open` (or directly to `resolved` if no architecture
decision remains). The two halves serve different audiences and use one edge decision remains). The two halves serve different audiences and use one edge
type (`depends_on`) — the reverse lookup uses `taskgraph dependents`. Do not type (`depends_on`); the reverse lookup uses `taskgraph dependents`. Do not
add a `blocks:` field; it duplicates the edge and creates a sync hazard. add a `blocks:` field; it duplicates the edge and creates a sync hazard.
## Review Injection ## Review Injection
@@ -559,8 +560,8 @@ Use graph analysis to determine where reviews should happen:
### Current (open-coordinator plugin) ### Current (open-coordinator plugin)
The Coordinator uses the `worktree` tool from the open-coordinator opencode The Coordinator uses the `worktree` tool from the open-coordinator opencode
plugin. It's a single tool with `{action, args}` dispatch — no separate plugin. It's a single tool with `{action, args}` dispatch, so there are no
enable/toggle steps. Role is auto-detected from session state. separate enable/toggle steps. Role is auto-detected from session state.
``` ```
1. Identify parallel work 1. Identify parallel work
@@ -613,13 +614,13 @@ a worktree.
When the open-memory plugin is available alongside open-coordinator, the When the open-memory plugin is available alongside open-coordinator, the
coordinator gains: coordinator gains:
- `memory({tool: "children", args: {sessionId: "..."}})` — view sub-agent - `memory({tool: "children", args: {sessionId: "..."}})`: view sub-agent
sessions spawned from the coordinator sessions spawned from the coordinator
- `memory({tool: "messages", args: {sessionId: "..."}})` — read a spawned - `memory({tool: "messages", args: {sessionId: "..."}})`: read a spawned
session's conversation for debugging session's conversation for debugging
- `memory({tool: "context"})` — check context window usage before long - `memory({tool: "context"})`: check context window usage before long
monitoring sessions monitoring sessions
- `memory_compact()` — proactively compact at natural breakpoints - `memory_compact()`: proactively compact at natural breakpoints
Implementation agents can also use `memory({tool: "context"})` and Implementation agents can also use `memory({tool: "context"})` and
`memory_compact()` to manage their context during long tasks. `memory_compact()` to manage their context during long tasks.
@@ -643,7 +644,7 @@ Once the hub is operational, coordination uses native operations:
``` ```
State moves from in-process tracking to Postgres `mappings` table. The State moves from in-process tracking to Postgres `mappings` table. The
open-coordinator plugin becomes unnecessary — the hub provides the same open-coordinator plugin becomes unnecessary: the hub provides the same
capabilities as server-side operations accessible from any environment. capabilities as server-side operations accessible from any environment.
## Document Structure ## Document Structure
@@ -710,7 +711,7 @@ Accepted | Proposed | Deprecated | Superseded
(Links to related specs and ADRs) (Links to related specs and ADRs)
``` ```
Numbering starts at 001 within each project. ADRs are stable — once Accepted, Numbering starts at 001 within each project. ADRs are stable: once Accepted,
they don't revert. If superseded, mark the old one and create a new one. they don't revert. If superseded, mark the old one and create a new one.
### Open Questions Format ### Open Questions Format
@@ -726,7 +727,7 @@ they don't revert. If superseded, mark the old one and create a new one.
- **Status**: open | resolved | deferred(scope) | partially resolved - **Status**: open | resolved | deferred(scope) | partially resolved
- **Priority**: high | medium | low - **Priority**: high | medium | low
- **Resolution**: (when resolved) - **Resolution**: (when resolved)
- **Blocked on**: (when deferred — concrete dependency that would unblock) - **Blocked on**: (when deferred; the concrete dependency that would unblock)
- **Cross-references**: OQ-NN, ADR-NNN - **Cross-references**: OQ-NN, ADR-NNN
``` ```
+5 -5
View File
@@ -109,7 +109,7 @@ dependencies = [
[[package]] [[package]]
name = "alkssh" name = "alkssh"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"alkcall", "alkcall",
"alktty", "alktty",
@@ -146,9 +146,9 @@ dependencies = [
[[package]] [[package]]
name = "alktty" name = "alktty"
version = "0.8.1" version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2265167842b7d3ea09bc235f48450d3dcf22049a2ca71f8155de00acbf97c226" checksum = "9cab9b0d3d6c1bdef2622799e3b95bb6063720153a124a57f4c60f95f3d42ea8"
dependencies = [ dependencies = [
"alkcall", "alkcall",
"async-trait", "async-trait",
@@ -166,9 +166,9 @@ dependencies = [
[[package]] [[package]]
name = "alkvault" name = "alkvault"
version = "0.1.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52e032b074f7fea71ea15528994c5b3a6503c082c519045bb3b0519281291918" checksum = "b2522ded10a506770779e72063422057bb63f1b75457c1742a3362072adfb1c4"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"base64", "base64",
+16 -16
View File
@@ -1,16 +1,16 @@
# alkssh fuzzing # alkssh fuzzing
libFuzzer targets for alkssh's ingress surfaces — the family standing libFuzzer targets for alkssh's ingress surfaces: the family standing
rule (anything worth fuzzing gets fuzzed), landed per the plan's wave-5 rule (anything worth fuzzing gets fuzzed), landed per the plan's wave-5
charter and the alkgit convention: the JSON schema gate itself is charter and the alkgit convention. The JSON schema gate itself is
alkcall's surface, family-fuzzed there; SSH framing, key exchange, and alkcall's surface, family-fuzzed there; SSH framing, key exchange, and
the channel multiplexer are russh's wire (family ADR-003) — the the channel multiplexer are russh's wire (family ADR-003); the
alkssh-owned post-framing ingress is what fuzzes here. alkssh-owned post-framing ingress is what fuzzes here.
## Layout ## Layout
- `fuzz_targets/` — one binary per target; thin `fuzz_target!` wrappers. - `fuzz_targets/`: one binary per target; thin `fuzz_target!` wrappers.
- `shared/` — the invariant logic, as a plain library so normal - `shared/`: the invariant logic, as a plain library so normal
`cargo test` (stable toolchain) can replay the committed corpora `cargo test` (stable toolchain) can replay the committed corpora
through the same invariants (`#[cfg(test)]` modules; quinn's CI through the same invariants (`#[cfg(test)]` modules; quinn's CI
pattern). The fuzz binaries are nightly-only; the shared crate is pattern). The fuzz binaries are nightly-only; the shared crate is
@@ -18,34 +18,34 @@ alkssh-owned post-framing ingress is what fuzzes here.
`#[doc(hidden)]` fuzz-visible exposures: the exec-grammar parse path `#[doc(hidden)]` fuzz-visible exposures: the exec-grammar parse path
in `src/dispatch.rs`, the key-decode arm `decode_key_arm` in in `src/dispatch.rs`, the key-decode arm `decode_key_arm` in
`src/key_source.rs`, and the `terminal_params` conversion in `src/key_source.rs`, and the `terminal_params` conversion in
`src/tty_map.rs` — the recorded exposure decisions: the ingresses are `src/tty_map.rs`; the recorded exposure decisions: the ingresses are
production-private, so the exposures are the production surfaces production-private, so the exposures are the production surfaces
themselves, never forks; the fingerprint ingress is fully public API) themselves, never forks; the fingerprint ingress is fully public API)
and must never become a dependency of the root crate. and must never become a dependency of the root crate.
- `corpus/<target>/` — committed hand-made seeds (regenerate with - `corpus/<target>/`: committed hand-made seeds (regenerate with
`python3 fuzz/gen_fuzz_seeds.py` from the repo root — must be a no-op `python3 fuzz/gen_fuzz_seeds.py` from the repo root; the result must
diff on the committed set). Only `seed-*` files are tracked; grown be a no-op diff on the committed set). Only `seed-*` files are tracked; grown
corpora (SHA-named units), artifacts, and coverage output are corpora (SHA-named units), artifacts, and coverage output are
gitignored and are **not** merged back into the committed seeds. gitignored and are **not** merged back into the committed seeds.
- `run-detached.sh` — mandatory runner for agent sessions: wraps - `run-detached.sh`: the mandatory runner for agent sessions. It wraps
`cargo fuzz run` in `setsid` + `nohup` + log redirection so an OOM `cargo fuzz run` in `setsid` + `nohup` + log redirection so an OOM
in a target can never take down the agent host. in a target can never take down the agent host.
- `gen_fuzz_seeds.py` — deterministic seed generator (quiche pattern). - `gen_fuzz_seeds.py`: deterministic seed generator (quiche pattern).
## Targets ## Targets
| Target | Drives | Invariants | | Target | Drives | Invariants |
|---|---|---| |---|---|---|
| `exec_parse` | the production exec-grammar parse (`parse_exec`, `src/dispatch.rs` — the handler's ingress shape: byte string → `from_utf8` fallback `""` → parse; the pre-parse `max_exec_command_len` cap and the git row's `invalid-repo-id` gate are the root contract rows' territory, the parse is everything behind them) | no-panic on any byte string (any length behind the cap); the typed routing classes (git-parseable → the git row, non-git → the session pipe path, service-only → decline-with-reason) agree with a from-spec grammar oracle (the differential pair) and are stable per input; for `Git` routes the repo-id gate classification is deterministic | | `exec_parse` | the production exec-grammar parse (`parse_exec`, `src/dispatch.rs`, the handler's ingress shape: byte string → `from_utf8` fallback `""` → parse; the pre-parse `max_exec_command_len` cap and the git row's `invalid-repo-id` gate are the root contract rows' territory, the parse is everything behind them) | no-panic on any byte string (any length behind the cap); the typed routing classes (git-parseable → the git row, non-git → the session pipe path, service-only → decline-with-reason) agree with a from-spec grammar oracle (the differential pair) and are stable per input; for `Git` routes the repo-id gate classification is deterministic |
| `fingerprint` | the crate's single fingerprint path ([`alkssh::fingerprint`] over `russh::keys::parse_public_key_base64` — ADR-004 decision 1) | no-panic on arbitrary key blobs (a non-blob is the parse's typed refusal); every accepted key's fingerprint is exactly the `SHA256:` + unpadded-standard-base64 contract shape (43-char body, canonical alphabet, decodes to a 32-byte digest, re-encodes canonically); determinism; the committed `ssh-keygen -lf` parity vectors still agree (the corpus-gate test row) | | `fingerprint` | the crate's single fingerprint path ([`alkssh::fingerprint`] over `russh::keys::parse_public_key_base64`, ADR-004 decision 1) | no-panic on arbitrary key blobs (a non-blob is the parse's typed refusal); every accepted key's fingerprint is exactly the `SHA256:` + unpadded-standard-base64 contract shape (43-char body, canonical alphabet, decodes to a 32-byte digest, re-encodes canonically); determinism; the committed `ssh-keygen -lf` parity vectors still agree (the corpus-gate test row) |
| `key_decode` | the decrypt-at-use chain's decode arm (`decode_key_arm`, `src/key_source.rs` — the production `decrypt_and_decode` conversion exposed `#[doc(hidden)]`) + the underlying `decode_secret_key` passphrase arm (robustness drive — the production chain always passes `None`) | no-panic on arbitrary plaintext shapes (the vault's `String` shape, lossy from arbitrary bytes) and passphrase shapes; every failure on the production arm is exactly the typed `KeySourceError::Decode` class — never a catch-all, never a panic; the failure text never carries the plaintext; the `Zeroizing` posture rides the drive (the wrap is what the adapter seam applies); determinism | | `key_decode` | the decrypt-at-use chain's decode arm (`decode_key_arm`, `src/key_source.rs`, the production `decrypt_and_decode` conversion exposed `#[doc(hidden)]`) + the underlying `decode_secret_key` passphrase arm (a robustness drive; the production chain always passes `None`) | no-panic on arbitrary plaintext shapes (the vault's `String` shape, lossy from arbitrary bytes) and passphrase shapes; every failure on the production arm is exactly the typed `KeySourceError::Decode` class, never a catch-all, never a panic; the failure text never carries the plaintext; the `Zeroizing` posture rides the drive (the wrap is what the adapter seam applies); determinism |
| `terminal_params` | the `pty_request` args → `TerminalParamsWire` conversion (`terminal_params`, `src/tty_map.rs` — OQ-44's SSH modes → reserved-modes `{}` JSON) | no-panic on arbitrary term strings, u32 dimensions, and mode blobs (the modes are never parsed — reserved); the dimension clamp is exactly the recorded cap (`min(value, 65535)`, never a magnitude truncation); `term` preserved verbatim; the reserved-modes JSON is always exactly `{}`; the full serialized wire shape is deterministic per input | | `terminal_params` | the `pty_request` args → `TerminalParamsWire` conversion (`terminal_params`, `src/tty_map.rs`, OQ-44's SSH modes → reserved-modes `{}` JSON) | no-panic on arbitrary term strings, u32 dimensions, and mode blobs (the modes are never parsed; they are reserved); the dimension clamp is exactly the recorded cap (`min(value, 65535)`, never a magnitude truncation); `term` preserved verbatim; the reserved-modes JSON is always exactly `{}`; the full serialized wire shape is deterministic per input |
## Commands ## Commands
`cargo fuzz build` and `cargo fuzz run` must be executed with `fuzz/` `cargo fuzz build` and `cargo fuzz run` must be executed with `fuzz/`
(or deeper) as the working directory so rustup selects the pinned (or deeper) as the working directory so rustup selects the pinned
nightly toolchain — the detached runner handles that itself. nightly toolchain; the detached runner handles that itself.
```sh ```sh
# build (nightly, pinned by rust-toolchain.toml inside fuzz/; run from fuzz/) # build (nightly, pinned by rust-toolchain.toml inside fuzz/; run from fuzz/)
+2 -2
View File
@@ -1,7 +1,7 @@
#![no_main] #![no_main]
//! The exec-grammar target (`mapping.md` §Session-channel dispatch — //! The exec-grammar target (`mapping.md` §Session-channel dispatch,
//! the git-parseable exec grammar + the dispatch parse path) — thin //! the git-parseable exec grammar + the dispatch parse path): a thin
//! wrapper over the shared invariant battery. //! wrapper over the shared invariant battery.
use libfuzzer_sys::fuzz_target; use libfuzzer_sys::fuzz_target;
+2 -2
View File
@@ -1,7 +1,7 @@
#![no_main] #![no_main]
//! The fingerprint-format target (ADR-004 decision 1 — the `SHA256:` + //! The fingerprint-format target (ADR-004 decision 1, the `SHA256:` +
//! unpadded base64 path) — thin wrapper over the shared invariant //! unpadded base64 path): a thin wrapper over the shared invariant
//! battery. //! battery.
use libfuzzer_sys::fuzz_target; use libfuzzer_sys::fuzz_target;
+2 -2
View File
@@ -1,7 +1,7 @@
#![no_main] #![no_main]
//! The key-decode target (ADR-005 decision 2 — the decrypt-at-use //! The key-decode target (ADR-005 decision 2, the decrypt-at-use
//! chain's `decode_secret_key` seam) — thin wrapper over the shared //! chain's `decode_secret_key` seam): a thin wrapper over the shared
//! invariant battery. //! invariant battery.
use libfuzzer_sys::fuzz_target; use libfuzzer_sys::fuzz_target;
+2 -2
View File
@@ -1,8 +1,8 @@
#![no_main] #![no_main]
//! The terminal-parameters target (`tty_map.rs` — the `pty_request` //! The terminal-parameters target (`tty_map.rs`, the `pty_request`
//! args → `TerminalParamsWire` conversion, the SSH modes → reserved //! args → `TerminalParamsWire` conversion, the SSH modes → reserved
//! modes JSON) — thin wrapper over the shared invariant battery. //! modes JSON): a thin wrapper over the shared invariant battery.
use libfuzzer_sys::fuzz_target; use libfuzzer_sys::fuzz_target;
+1 -1
View File
@@ -1,7 +1,7 @@
//! The corpus-replay plumbing: the committed seeds replay through the //! The corpus-replay plumbing: the committed seeds replay through the
//! invariant batteries on the stable toolchain (`cargo test //! invariant batteries on the stable toolchain (`cargo test
//! --manifest-path fuzz/shared/Cargo.toml` runs this in //! --manifest-path fuzz/shared/Cargo.toml` runs this in
//! `#[cfg(test)]` modules across the ingress units — the quinn CI //! `#[cfg(test)]` modules across the ingress units, the quinn CI
//! pattern), so the gate never needs a fuzzing session. //! pattern), so the gate never needs a fuzzing session.
pub fn read_corpus(target: &str) -> Vec<Vec<u8>> { pub fn read_corpus(target: &str) -> Vec<Vec<u8>> {
+7 -7
View File
@@ -1,10 +1,10 @@
//! The exec-grammar ingress (`mapping.md` §Session-channel dispatch, //! The exec-grammar ingress (`mapping.md` §Session-channel dispatch,
//! `dispatch.rs`): the production `parse_exec` path driven on arbitrary //! `dispatch.rs`): the production `parse_exec` path driven on arbitrary
//! command strings — no-panic, typed routing classes, and agreement //! command strings: no-panic, typed routing classes, and agreement
//! with a from-spec re-derivation of the grammar. The production //! with a from-spec re-derivation of the grammar. The production
//! ingress shape is the handler's: the per-length cap //! ingress shape is the handler's: the per-length cap
//! (`Limits::max_exec_command_len`) is enforced *before* the parse //! (`Limits::max_exec_command_len`) is enforced *before* the parse
//! (decline first, alloc never — the cap's pre-parse placement is //! (decline first, alloc never; the cap's pre-parse placement is
//! pinned by the root's contract rows in `tests/dispatch_core.rs`); //! pinned by the root's contract rows in `tests/dispatch_core.rs`);
//! everything behind the cap is this battery: a byte string → //! everything behind the cap is this battery: a byte string →
//! `std::str::from_utf8(..).unwrap_or("")` → the parse (invalid UTF-8 //! `std::str::from_utf8(..).unwrap_or("")` → the parse (invalid UTF-8
@@ -43,7 +43,7 @@ pub fn fuzz_exec_parse(data: &[u8]) {
/// The from-spec oracle: the grammar re-derived from the dispatch /// The from-spec oracle: the grammar re-derived from the dispatch
/// module's documented rules, sharing no code with the /// module's documented rules, sharing no code with the
/// implementation — the differential pair (the repo_id target's /// implementation, the differential pair (the repo_id target's
/// oracle pattern). /// oracle pattern).
fn spec_parse(command: &str) -> ExecRoute { fn spec_parse(command: &str) -> ExecRoute {
let (token, argument) = match command.split_once(' ') { let (token, argument) = match command.split_once(' ') {
@@ -64,7 +64,7 @@ fn spec_parse(command: &str) -> ExecRoute {
} }
/// The serving-service arm of the oracle: a serving token must carry /// The serving-service arm of the oracle: a serving token must carry
/// a non-empty, path-shaped argument (SSH exec must carry the repo — /// a non-empty, path-shaped argument (SSH exec must carry the repo;
/// service-only is the decline-with-reason class; anything else /// service-only is the decline-with-reason class; anything else
/// unparseable is the pipe row). /// unparseable is the pipe row).
fn spec_serving(service: GitServiceRoute, argument: Option<&str>) -> ExecRoute { fn spec_serving(service: GitServiceRoute, argument: Option<&str>) -> ExecRoute {
@@ -78,9 +78,9 @@ fn spec_serving(service: GitServiceRoute, argument: Option<&str>) -> ExecRoute {
} }
} }
/// The argument grammar re-derived: a path-shaped argument — fully /// The argument grammar re-derived: a path-shaped argument, either
/// single-quote-wrapped (a non-empty inner ending in a quote) or /// fully single-quote-wrapped (a non-empty inner ending in a quote) or
/// unquoted absolute — normalized by stripping the quotes, one /// unquoted absolute, normalized by stripping the quotes, one
/// leading `/`, and one trailing `.git` suffix (an empty-after-strip /// leading `/`, and one trailing `.git` suffix (an empty-after-strip
/// prefix keeps the suffix; the git row's validation declines the /// prefix keeps the suffix; the git row's validation declines the
/// empties, never a silent emptying). /// empties, never a silent emptying).
+4 -4
View File
@@ -1,9 +1,9 @@
//! The fingerprint format ingress (ADR-004 decision 1, //! The fingerprint format ingress (ADR-004 decision 1,
//! `fingerprint.rs`): arbitrary key blobs through the crate's single //! `fingerprint.rs`): arbitrary key blobs through the crate's single
//! fingerprint path — the composition of russh's public OpenSSH blob //! fingerprint path: the composition of russh's public OpenSSH blob
//! parse (`russh::keys::parse_public_key_base64`, the same re-export //! parse (`russh::keys::parse_public_key_base64`, the same re-export
//! the crate's surface rides) and the crate's public //! the crate's surface rides) and the crate's public
//! [`alkssh::fingerprint`]. The ingress is fully public API — the //! [`alkssh::fingerprint`]. The ingress is fully public API; the
//! recorded exposure decision needs no `#[doc(hidden)]` surface. //! recorded exposure decision needs no `#[doc(hidden)]` surface.
//! //!
//! Invariants: no-panic on any byte string (a non-blob is the parse's //! Invariants: no-panic on any byte string (a non-blob is the parse's
@@ -11,7 +11,7 @@
//! accepted key's fingerprint is exactly the `SHA256:` + unpadded //! accepted key's fingerprint is exactly the `SHA256:` + unpadded
//! standard base64 contract shape (one `:` separator, 43-char body, //! standard base64 contract shape (one `:` separator, 43-char body,
//! canonical alphabet, 32-byte digest decoded exactly); determinism //! canonical alphabet, 32-byte digest decoded exactly); determinism
//! (same key, same string — and the same string re-parses to the same //! (same key, same string; the same string re-parses to the same
//! verdict). The committed `ssh-keygen -lf` parity vectors //! verdict). The committed `ssh-keygen -lf` parity vectors
//! (`tests/vectors/`) are replayed by the corpus-gate tests below. //! (`tests/vectors/`) are replayed by the corpus-gate tests below.
@@ -98,7 +98,7 @@ fn decode_unpadded(body: &str) -> Option<Vec<u8>> {
Some(out) Some(out)
} }
/// A from-spec unpadded standard-base64 encoder — the re-encode half. /// A from-spec unpadded standard-base64 encoder: the re-encode half.
fn encode_unpadded(bytes: &[u8]) -> String { fn encode_unpadded(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = Vec::new(); let mut out = Vec::new();
+16 -14
View File
@@ -1,28 +1,30 @@
//! The key-decode ingress (ADR-005 decision 2, `key_source.rs`): the //! The key-decode ingress (ADR-005 decision 2, `key_source.rs`): the
//! decrypt-at-use chain's one-time `decode_secret_key` conversion — //! decrypt-at-use chain's one-time `decode_secret_key` conversion,
//! driven through the production arm itself //! driven through the production arm itself
//! ([`alkssh::decode_key_arm`], the `#[doc(hidden)]` exposure that is //! ([`alkssh::decode_key_arm`], the `#[doc(hidden)]` exposure that is
//! `decrypt_and_decode`'s exact decode stage, not a fork). //! `decrypt_and_decode`'s exact decode stage, not a fork).
//! //!
//! Invariants: no-panic on arbitrary plaintext shapes (the production //! Invariants: no-panic on arbitrary plaintext shapes (the production
//! plaintext is the vault's `String` — valid UTF-8 by return type; the //! plaintext is the vault's `Zeroizing<String>`, valid UTF-8 by
//! battery shapes arbitrary bytes into that form via the lossy //! return type; the battery shapes arbitrary bytes into that form via
//! conversion and drives it through the same `Zeroizing` wrap the //! the lossy conversion and drives it through the same zeroized
//! adapter seam applies) and arbitrary passphrase shapes (the //! wrap the vault's `decrypt` return applies, alkssh 0.2.0 seam) and
//! underlying decode's passphrase arm — the production chain always //! arbitrary passphrase shapes (the
//! underlying decode's passphrase arm; the production chain always
//! passes `None`; the `Some` arm is robustness drive only); every //! passes `None`; the `Some` arm is robustness drive only); every
//! failure is the typed [`KeySourceError::Decode`] class — never a //! failure is the typed [`KeySourceError::Decode`] class, never a
//! catch-all, never another variant on this arm, never a panic; the //! catch-all, never another variant on this arm, never a panic; the
//! failure text never carries the plaintext (the assertable half of //! failure text never carries the plaintext (the assertable half of
//! the Zeroizing posture — the plaintext exists solely inside the //! the Zeroizing posture: the plaintext exists solely inside the
//! call, dropped zeroized; no retention, no static cache — and the //! call, dropped zeroized, with no retention and no static cache; the
//! typed error's only payload is the decode machinery's message). //! typed error's only payload is the decode machinery's message
//! class, never the plaintext).
//! //!
//! The no-echo invariant's assertable form is *the bounded-message //! The no-echo invariant's assertable form is *the bounded-message
//! pair* (reshaped in-task after the first campaign's finding): the //! pair* (reshaped in-task after the first campaign's finding): the
//! failure text is a bounded fixed message (≤ 256 bytes — the decode //! failure text is a bounded fixed message (≤ 256 bytes; the decode
//! machinery's vocabulary, never a growable echo), and no 256-byte //! machinery's vocabulary, never a growable echo), and no 256-byte
//! contiguous plaintext run appears in it — which, given the bound, //! contiguous plaintext run appears in it, which, given the bound,
//! cannot false-fire (a string shorter than 256 bytes cannot contain a //! cannot false-fire (a string shorter than 256 bytes cannot contain a
//! 256-byte run). A plaintext-`contains`-message check in the other //! 256-byte run). A plaintext-`contains`-message check in the other
//! direction was unsound: an attacker-chosen plaintext can be a //! direction was unsound: an attacker-chosen plaintext can be a
@@ -68,7 +70,7 @@ fn fingerprint(key: &russh::keys::PrivateKey) -> String {
} }
/// The typed-class invariant: `Ok` or exactly /// The typed-class invariant: `Ok` or exactly
/// [`KeySourceError::Decode`] — the decode arm's only failure shape — /// [`KeySourceError::Decode`] (the decode arm's only failure shape),
/// and the Decode text is the bounded fixed-message pair (see the /// and the Decode text is the bounded fixed-message pair (see the
/// module docs; the two assertions are jointly airtight). /// module docs; the two assertions are jointly airtight).
fn typed_decode_class(outcome: &Result<russh::keys::PrivateKey, KeySourceError>, plaintext: &str) { fn typed_decode_class(outcome: &Result<russh::keys::PrivateKey, KeySourceError>, plaintext: &str) {
@@ -121,7 +123,7 @@ mod invariants {
} }
/// A plaintext that is not OpenSSH-PEM key material is a /// A plaintext that is not OpenSSH-PEM key material is a
/// [`KeySourceError::Decode`] refusal (the inner-format contract — /// [`KeySourceError::Decode`] refusal (the inner-format contract:
/// decision 1, never a fallback). /// decision 1, never a fallback).
#[test] #[test]
fn non_key_plaintext_is_the_typed_decode_refusal() { fn non_key_plaintext_is_the_typed_decode_refusal() {
+5 -5
View File
@@ -1,22 +1,22 @@
//! The terminal-parameters ingress (`tty_map.rs`, OQ-44's reserved //! The terminal-parameters ingress (`tty_map.rs`, OQ-44's reserved
//! modes): the `pty_request` args → `TerminalParamsWire` conversion //! modes): the `pty_request` args → `TerminalParamsWire` conversion
//! driven through the production [terminal_params] surface (the //! driven through the production [terminal_params] surface (the
//! `#[doc(hidden)]` exposure — `handler.rs`'s `pty_request` row //! `#[doc(hidden)]` exposure: `handler.rs`'s `pty_request` row
//! constructs the stored params with exactly this function). //! constructs the stored params with exactly this function).
//! //!
//! The harness's framed feed (deterministic, seed-friendly): //! The harness's framed feed (deterministic, seed-friendly):
//! `term_len` (u32 LE, bounded to the remaining input) + the term //! `term_len` (u32 LE, bounded to the remaining input) + the term
//! bytes + four u32 LE dimensions (`cols`/`rows`/pixel width/height, //! bytes + four u32 LE dimensions (`cols`/`rows`/pixel width/height,
//! zero-defaulted when the input runs out) + an arbitrary trailing //! zero-defaulted when the input runs out) + an arbitrary trailing
//! mode blob. The mode blob is *never parsed* — the SSH ttymodes ride //! mode blob. The mode blob is *never parsed*; the SSH ttymodes ride
//! the reserved `{}` JSON (OQ-44, backends ignore modes in v1) — so //! the reserved `{}` JSON (OQ-44, backends ignore modes in v1), so
//! the battery's totality over arbitrary mode blobs is by //! the battery's totality over arbitrary mode blobs is by
//! construction; the framing demonstrates it (the conversion's //! construction; the framing demonstrates it (the conversion's
//! outcome never varies with the blob). //! outcome never varies with the blob).
//! //!
//! Invariants: no-panic on any term string (empty, unicode, control //! Invariants: no-panic on any term string (empty, unicode, control
//! characters) and any u32 dimensions; the dimension clamp is the //! characters) and any u32 dimensions; the dimension clamp is the
//! recorded cap (`min(value, 65535)` — a dimension beyond 65535 is //! recorded cap (`min(value, 65535)`; a dimension beyond 65535 is
//! nonsense on both papers) computed exactly, never a magnitude //! nonsense on both papers) computed exactly, never a magnitude
//! truncation; `term` is preserved verbatim; the reserved-modes JSON //! truncation; `term` is preserved verbatim; the reserved-modes JSON
//! is always exactly the empty object; the full wire shape is //! is always exactly the empty object; the full wire shape is
@@ -89,7 +89,7 @@ pub fn fuzz_terminal_params(data: &[u8]) {
} }
} }
/// The cursor's u32 (zero-defaulted when the framed field is absent — /// The cursor's u32 (zero-defaulted when the framed field is absent;
/// the feed is total over any input). /// the feed is total over any input).
fn take_u32(cursor: &mut &[u8]) -> u32 { fn take_u32(cursor: &mut &[u8]) -> u32 {
if cursor.len() >= 4 { if cursor.len() >= 4 {
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# The decline-matrix runner (tasks/implementation/w5-decline-matrix.md): # The decline-matrix runner (tasks/implementation/w5-decline-matrix.md):
# the per-build verification set as one command — `cargo test` per # the per-build verification set as one command: `cargo test` per
# feature build (none / git / session / tunnel / sftp-only / # feature build (none / git / session / tunnel / sftp-only /
# --all-features; the sftp-only run is the no-op-feature fact itself # --all-features; the sftp-only run is the no-op-feature fact itself
# verified), clippy ×2 (-D warnings, with and without # verified), clippy ×2 (-D warnings, with and without
+19 -18
View File
@@ -1,8 +1,8 @@
//! The server-direction mount point (ADR-001) — [`SshAdapter`], the //! The server-direction mount point (ADR-001): [`SshAdapter`], the
//! constructed russh server stack every mapping composes into. //! constructed russh server stack every mapping composes into.
//! //!
//! Construction ([`SshAdapter::new`], ADR-005 decision 6): the //! Construction ([`SshAdapter::new`], ADR-005 decision 6): the
//! injected [`HostKeySource`] loads **decrypt-at-construction** — a //! injected [`HostKeySource`] loads **decrypt-at-construction**; a
//! locked / absent / decryption-failed / unknown-version / undecodable //! locked / absent / decryption-failed / unknown-version / undecodable
//! load means **no server is constructed**: the error surfaces as //! load means **no server is constructed**: the error surfaces as
//! [`AdapterError::Construction`] with the original [`KeySourceError`] //! [`AdapterError::Construction`] with the original [`KeySourceError`]
@@ -11,34 +11,35 @@
//! host-key source contract's [`KeySourceError::Absent`] shape). //! host-key source contract's [`KeySourceError::Absent`] shape).
//! //!
//! The russh server `Config` (ADR-002 surface notes; ADR-004 decision //! The russh server `Config` (ADR-002 surface notes; ADR-004 decision
//! 4): `methods` is `{publickey}` **exactly** (honest advertisement — //! 4): `methods` is `{publickey}` **exactly** (honest advertisement:
//! `MethodSet::from(&[MethodKind::PublicKey][..])`, never russh's //! `MethodSet::from(&[MethodKind::PublicKey][..])`, never russh's
//! `server_supported()`), the loaded host keys ride `Config::keys`, //! `server_supported()`), the loaded host keys ride `Config::keys`,
//! and the [`Limits`] knobs map one-for-one: //! and the [`Limits`] knobs map one-for-one:
//! `window_size` / `maximum_packet_size` / `channel_buffer_size` / //! `window_size` / `maximum_packet_size` / `channel_buffer_size` /
//! `max_auth_attempts` verbatim, `inactivity_timeout` as **always //! `max_auth_attempts` verbatim, `inactivity_timeout` as **always
//! `Some`** — the bounded-resources posture (the crate never //! `Some`**, the bounded-resources posture (the crate never
//! constructs a session with no wall clock). Everything else stays at //! constructs a session with no wall clock). Everything else stays at
//! russh 0.65's own default posture (`Config::default()`, left //! russh 0.65's own default posture (`Config::default()`, left
//! un-touched deliberately): the rekey `Limits` stay russh-enforced at //! un-touched deliberately): the rekey `Limits` stay russh-enforced at
//! their RFC-4253 §9 defaults, `auth_rejection_time` stays russh's //! their RFC-4253 §9 defaults, `auth_rejection_time` stays russh's
//! constant-rejection knob, and the preferred-algorithm set stays //! constant-rejection knob, and the preferred-algorithm set stays
//! russh's — the ed25519-only key-algorithm posture holds by the //! russh's; the ed25519-only key-algorithm posture holds by the
//! loaded key set itself (ADR-002 decision 4) and by the auth bridge's //! loaded key set itself (ADR-002 decision 4) and by the auth bridge's
//! explicit non-ed25519 decline (`w2-auth-bridge`). //! explicit non-ed25519 decline (`w2-auth-bridge`).
//! //!
//! The mount ([`SshAdapter::handle`], ADR-001's contract): a generic //! The mount ([`SshAdapter::handle`], ADR-001's contract): a generic
//! byte-duplex stream in, the full russh server stack per stream via //! byte-duplex stream in, the full russh server stack per stream via
//! [`russh::server::run_stream`], returned **run to completion** — //! [`russh::server::run_stream`], returned **run to completion**;
//! the edge-termination shape only, no ALPN code, no relay leg (the //! the edge-termination shape only, no ALPN code, no relay leg (the
//! `alk/ssh` pin is a ledger entry; nothing here speaks it). The //! `alk/ssh` pin is a ledger entry; nothing here speaks it). The
//! in-flight framing is the alkhttp door precedent: `handle` is an //! in-flight framing is the alkhttp door precedent: `handle` is an
//! async per-stream call whose completion is the session's end, so the //! async per-stream call whose completion is the session's end, so the
//! assembly's accept loop mounts per stream via `tokio::spawn` — the //! assembly's accept loop mounts per stream via `tokio::spawn`, and
//! serving loop itself stays spawned inside russh (ADR-006 decision 4, //! the serving loop itself stays spawned inside russh (ADR-006
//! which `run_stream` already satisfies: the session's packet loop //! decision 4, which `run_stream` already satisfies: the session's
//! leaves on its own task), and the mount's return type stays //! packet loop leaves on its own task), and the mount's return type
//! `Result<(), AdapterError>` — no russh type rides the signature. //! stays `Result<(), AdapterError>` (no russh type rides the
//! signature).
use std::sync::Arc; use std::sync::Arc;
@@ -53,7 +54,7 @@ use crate::handler::SessionHandler;
#[cfg(feature = "tunnel")] #[cfg(feature = "tunnel")]
use crate::tunnel_map; use crate::tunnel_map;
/// The constructed server stack — the substrate every channel-type /// The constructed server stack, the substrate every channel-type
/// mapping composes into (ADR-001's `SshAdapter`). /// mapping composes into (ADR-001's `SshAdapter`).
/// ///
/// Built by [`SshAdapter::new`] from the assembly-supplied /// Built by [`SshAdapter::new`] from the assembly-supplied
@@ -67,8 +68,8 @@ pub struct SshAdapter {
} }
impl SshAdapter { impl SshAdapter {
/// Constructs the adapter — decrypt-at-construction of the host /// Constructs the adapter: decrypt-at-construction of the host
/// key set (ADR-005 decision 6): a failed or empty load is no /// key set (ADR-005 decision 6); a failed or empty load is no
/// server, with the [`KeySourceError`] refused through /// server, with the [`KeySourceError`] refused through
/// [`AdapterError::Construction`]. /// [`AdapterError::Construction`].
pub async fn new(serve: ServeConfig) -> Result<Self, AdapterError> { pub async fn new(serve: ServeConfig) -> Result<Self, AdapterError> {
@@ -98,7 +99,7 @@ impl SshAdapter {
}) })
} }
/// The mounted russh server `Config` — in-crate inspection for the /// The mounted russh server `Config`, in-crate inspection for the
/// contract tests (advertisement and knob mapping); not public /// contract tests (advertisement and knob mapping); not public
/// surface: the mount's behavior is observable on the wire, never /// surface: the mount's behavior is observable on the wire, never
/// through a russh type leak. /// through a russh type leak.
@@ -108,7 +109,7 @@ impl SshAdapter {
} }
/// The mount (ADR-001): mounts the full russh server stack on one /// The mount (ADR-001): mounts the full russh server stack on one
/// generic duplex byte stream and runs it to completion — an /// generic duplex byte stream and runs it to completion; an
/// accepted vanilla-SSH transport is authenticated and served on /// accepted vanilla-SSH transport is authenticated and served on
/// this call's task; the assembly's accept loop mounts per stream /// this call's task; the assembly's accept loop mounts per stream
/// with `tokio::spawn(adapter.handle(stream))`. /// with `tokio::spawn(adapter.handle(stream))`.
@@ -126,7 +127,7 @@ impl SshAdapter {
} }
} }
/// The adapter's own `Config` construction from the serve-config — the /// The adapter's own `Config` construction from the serve-config: the
/// documented subset mapped one-for-one, everything else at russh's /// documented subset mapped one-for-one, everything else at russh's
/// default posture (see the module docs; ADR-002 surface notes). /// default posture (see the module docs; ADR-002 surface notes).
fn build_server_config(serve: &ServeConfig, keys: Vec<PrivateKey>) -> russh::server::Config { fn build_server_config(serve: &ServeConfig, keys: Vec<PrivateKey>) -> russh::server::Config {
@@ -160,7 +161,7 @@ mod tests {
const NOOP: alkcall::core::auth::NoopIdentityProvider = const NOOP: alkcall::core::auth::NoopIdentityProvider =
alkcall::core::auth::NoopIdentityProvider; alkcall::core::auth::NoopIdentityProvider;
/// The committed test-key fixtures — the wave-1 fixture set, read /// The committed test-key fixtures (the wave-1 fixture set), read
/// through `include_str!` per the wave-1 convention (the /// through `include_str!` per the wave-1 convention (the
/// `tests/common` double module is the tests/-side owner; src-unit /// `tests/common` double module is the tests/-side owner; src-unit
/// tests read the fixture files, never its exports). /// tests read the fixture files, never its exports).
+24 -24
View File
@@ -1,9 +1,9 @@
//! The in-crate concurrent-channel cap (`w2-bounded-resources`) — the //! The in-crate concurrent-channel cap (`w2-bounded-resources`): the
//! bound itself, carried by the per-connection handler //! bound itself, carried by the per-connection handler
//! ([`crate::handler`]) and enforced at every `channel_open_*` accept //! ([`crate::handler`]) and enforced at every `channel_open_*` accept
//! path (`overview.md` §Bounded-resources policy: "concurrent channels //! path (`overview.md` §Bounded-resources policy: "concurrent channels
//! per session — ours (in-crate) + per-identity policy when bridged"; //! per session (ours, in-crate) + per-identity policy when bridged";
//! AGENTS.md convention 9 — SSH servers are internet-facing; unbounded //! AGENTS.md convention 9: SSH servers are internet-facing; unbounded
//! loops and unbounded buffers are bugs). //! loops and unbounded buffers are bugs).
//! //!
//! ## Shape //! ## Shape
@@ -11,12 +11,12 @@
//! [`ChannelCap`] is a per-session count of active channels. Admission //! [`ChannelCap`] is a per-session count of active channels. Admission
//! ([`ChannelCap::admit`]) is the check-and-increment the accept paths //! ([`ChannelCap::admit`]) is the check-and-increment the accept paths
//! call: room under the cap admits (the count rises), otherwise the //! call: room under the cap admits (the count rises), otherwise the
//! open is refused — a clean decline, **never queued, never half-open** //! open is refused: a clean decline, **never queued, never half-open**
//! (the fail-closed posture; the refusal reason is //! (the fail-closed posture; the refusal reason is
//! [`crate::dispatch::cap_exceeded_open_decline`]). Release //! [`crate::dispatch::cap_exceeded_open_decline`]). Release
//! ([`ChannelCap::release`]) fires from the `channel_close` hook for //! ([`ChannelCap::release`]) fires from the `channel_close` hook for
//! established channels — and, since `w3-tunnel-direct`, from a //! established channels, and, since `w3-tunnel-direct`, from a
//! spawned route-1 bridge task on a fabric-open failure — and, since //! spawned route-1 bridge task on a fabric-open failure, and, since
//! `w3-tunnel-listen`, from the spawned `-R` arrival pumps on the //! `w3-tunnel-listen`, from the spawned `-R` arrival pumps on the
//! never-established paths (client-rejected open, dead session): see //! never-established paths (client-rejected open, dead session): see
//! the release-sites section. //! the release-sites section.
@@ -27,36 +27,36 @@
//! Every admission has exactly one release, and the paths are //! Every admission has exactly one release, and the paths are
//! disjoint by the wire shape: //! disjoint by the wire shape:
//! //!
//! 1. **The established-channel close** — the `channel_close` hook. //! 1. **The established-channel close**: the `channel_close` hook.
//! russh routes `CHANNEL_CLOSE` only for channels it considers //! russh routes `CHANNEL_CLOSE` only for channels it considers
//! established; a declined open never established one, and a close //! established; a declined open never established one, and a close
//! is consumed at most once (russh removes the channel before the //! is consumed at most once (russh removes the channel before the
//! hook). //! hook).
//! 2. **The admitted-but-rejected bridge** — the route-1 bridge's //! 2. **The admitted-but-rejected bridge**: the route-1 bridge's
//! failure path (`w3-tunnel-direct`). The open hook admits and //! failure path (`w3-tunnel-direct`). The open hook admits and
//! holds the count across the fabric open; on fabric failure the //! holds the count across the fabric open; on fabric failure the
//! open is rejected with the explicit `ConnectFailed` //! open is rejected with the explicit `ConnectFailed`
//! (ADR-006 decision 1: accept only after the fabric open //! (ADR-006 decision 1: accept only after the fabric open
//! succeeds), the channel never establishes, and *no //! succeeds), the channel never establishes, and *no
//! `channel_close` can ever fire* — so the count must be released //! `channel_close` can ever fire*, so the count must be released
//! from where the failure is known: inside the spawned bridge //! from where the failure is known: inside the spawned bridge
//! task, **before** the reject is answered (the very next open //! task, **before** the reject is answered (the very next open
//! then observes the recovered budget — pinned by wire test). A //! then observes the recovered budget; pinned by wire test). A
//! success never releases there (its close rides path 1). //! success never releases there (its close rides path 1).
//! 3. **The `-R` arrival pumps** (`w3-tunnel-listen`): a fabric //! 3. **The `-R` arrival pumps** (`w3-tunnel-listen`): a fabric
//! arrival is admitted into the owning route's count and opens the //! arrival is admitted into the owning route's count and opens the
//! SSH `forwarded-tcpip`/`forwarded-streamlocal` channel toward //! SSH `forwarded-tcpip`/`forwarded-streamlocal` channel toward
//! the vanilla client through the session handle. A rejected open — //! the vanilla client through the session handle. A rejected open
//! the client's own refusal, or a dead session's send failure — //! (the client's own refusal, or a dead session's send failure)
//! never establishes the channel, so the count releases inside the //! never establishes the channel, so the count releases inside the
//! arrival task exactly once (an established channel's release is //! arrival task exactly once (an established channel's release is
//! path 1's). These are the mirror of site 2 on the server-initiated //! path 1's). These are the mirror of site 2 on the server-initiated
//! open direction — the acceptor vocabulary's cap rows. //! open direction, the acceptor vocabulary's cap rows.
//! //!
//! ## Why the mutex (the single-writer reasoning, updated twice) //! ## Why the mutex (the single-writer reasoning, updated twice)
//! //!
//! The wave-2 shape was a bare `usize` field written only by the //! The wave-2 shape was a bare `usize` field written only by the
//! session's packet loop — sound because every hook runs inline on //! session's packet loop, sound because every hook runs inline on
//! that loop with exclusive `&mut self` access. The bridge release //! that loop with exclusive `&mut self` access. The bridge release
//! (`w3-tunnel-direct`) and the arrival admission (`w3-tunnel-listen`) //! (`w3-tunnel-direct`) and the arrival admission (`w3-tunnel-listen`)
//! both break the single-writer property: those tasks are alkcall //! both break the single-writer property: those tasks are alkcall
@@ -66,12 +66,12 @@
//! critical sections are single integer ops (no await inside the //! critical sections are single integer ops (no await inside the
//! lock), and all sites saturating. Channels never closed before //! lock), and all sites saturating. Channels never closed before
//! session teardown stay counted until the handler dies with the //! session teardown stay counted until the handler dies with the
//! session — per-session state, gone with the connection. //! session (per-session state, gone with the connection).
use parking_lot::Mutex; use parking_lot::Mutex;
/// The per-session active-channel count for the in-crate /// The per-session active-channel count for the in-crate
/// concurrent-channel cap — see the module docs for the shape, the /// concurrent-channel cap; see the module docs for the shape, the
/// release sites, and the writer/soundness reasoning. Shared with the /// release sites, and the writer/soundness reasoning. Shared with the
/// spawned route-1 bridge tasks ([`Arc<ChannelCap>`]) exactly so the /// spawned route-1 bridge tasks ([`Arc<ChannelCap>`]) exactly so the
/// fabric-failure release can fire from the task that knows it. /// fabric-failure release can fire from the task that knows it.
@@ -82,13 +82,13 @@ pub(crate) struct ChannelCap {
impl ChannelCap { impl ChannelCap {
/// The accept-path admission decision: room under `cap` increments /// The accept-path admission decision: room under `cap` increments
/// the count and admits; otherwise refuses (fail-closed — the /// the count and admits; otherwise refuses (fail-closed: the
/// caller answers the open with a clean decline). `cap == 0` /// caller answers the open with a clean decline). `cap == 0`
/// refuses every open. Call sites: the packet loop's open hooks /// refuses every open. Call sites: the packet loop's open hooks
/// `w2`-style, and — since `w3-tunnel-listen` — the `-R` arrival /// `w2`-style, and, since `w3-tunnel-listen`, the `-R` arrival
/// tasks (the door's forwarded-arrival admission: an arrival is /// tasks (the door's forwarded-arrival admission: an arrival is
/// admitted on the owning route's count inside the spawned /// admitted on the owning route's count inside the spawned
/// arrival pump — see the module doc's updated single-writer /// arrival pump; see the module doc's updated single-writer
/// reasoning). The mutex makes both sound. /// reasoning). The mutex makes both sound.
pub(crate) fn admit(&self, cap: usize) -> bool { pub(crate) fn admit(&self, cap: usize) -> bool {
let mut active = self.active.lock(); let mut active = self.active.lock();
@@ -110,7 +110,7 @@ impl ChannelCap {
*active = active.saturating_sub(1); *active = active.saturating_sub(1);
} }
/// The active count — in-crate inspection for the unit rows. /// The active count, in-crate inspection for the unit rows.
#[cfg(test)] #[cfg(test)]
pub(crate) fn active(&self) -> usize { pub(crate) fn active(&self) -> usize {
*self.active.lock() *self.active.lock()
@@ -136,7 +136,7 @@ mod tests {
} }
/// - *Contract stamp:* the task's cap mechanics (a close recovers /// - *Contract stamp:* the task's cap mechanics (a close recovers
/// the cap — the count decrements on close). /// the cap; the count decrements on close).
#[test] #[test]
fn release_recovers_the_cap() { fn release_recovers_the_cap() {
let cap = ChannelCap::default(); let cap = ChannelCap::default();
@@ -150,7 +150,7 @@ mod tests {
} }
/// - *Contract stamp:* the fail-closed cap posture (`cap == 0` is /// - *Contract stamp:* the fail-closed cap posture (`cap == 0` is
/// a valid, assembly-tunable refusal of every open — never a /// a valid, assembly-tunable refusal of every open, never a
/// bypass). /// bypass).
#[test] #[test]
fn zero_cap_refuses_every_open() { fn zero_cap_refuses_every_open() {
@@ -177,9 +177,9 @@ mod tests {
/// - *Contract stamp:* `w3-tunnel-direct`'s recorded release /// - *Contract stamp:* `w3-tunnel-direct`'s recorded release
/// mechanism (the admitted-but-rejected bridge releases inside /// mechanism (the admitted-but-rejected bridge releases inside
/// the spawned task): the cap is shared safely — admission from /// the spawned task): the cap is shared safely (admission from
/// the packet-loop site, release from anywhere that holds the /// the packet-loop site, release from anywhere that holds the
/// `Arc` — and disjoint releases never double-count. /// `Arc`), and disjoint releases never double-count.
#[test] #[test]
fn the_cap_is_shareable_between_the_loop_and_bridge_tasks() { fn the_cap_is_shareable_between_the_loop_and_bridge_tasks() {
let cap = std::sync::Arc::new(ChannelCap::default()); let cap = std::sync::Arc::new(ChannelCap::default());
+38 -38
View File
@@ -1,4 +1,4 @@
//! The dial surface (`w4-dial`) — the door *outward* (`client.md` //! The dial surface (`w4-dial`), the door *outward* (`client.md`
//! §The dial surface), the client-direction mirror of //! §The dial surface), the client-direction mirror of
//! [`crate::adapter`]'s mount: the russh client stack every wave-4 op //! [`crate::adapter`]'s mount: the russh client stack every wave-4 op
//! row composes into. //! row composes into.
@@ -7,7 +7,7 @@
//! whole authenticated-dial sequence, **fail-closed at every step**: //! whole authenticated-dial sequence, **fail-closed at every step**:
//! //!
//! 1. **The decrypt-at-dial key load** (ADR-005 decision 2): the //! 1. **The decrypt-at-dial key load** (ADR-005 decision 2): the
//! injected [`KeySource::load`] runs *here and nowhere else* — //! injected [`KeySource::load`] runs *here and nowhere else*:
//! locked / absent / decryption / unknown-version / decode are all //! locked / absent / decryption / unknown-version / decode are all
//! **no-dial outcomes** ([`DialError::KeySource`], the variant //! **no-dial outcomes** ([`DialError::KeySource`], the variant
//! preserved). The decoded key lives for this dial; the vault is //! preserved). The decoded key lives for this dial; the vault is
@@ -15,18 +15,18 @@
//! established connections streaming, POC-2 §2). //! established connections streaming, POC-2 §2).
//! 2. **The bounded russh client `Config`** ([`russh_client_config`]) //! 2. **The bounded russh client `Config`** ([`russh_client_config`])
//! from the [`Limits`]: the keepalive 15 s / 3 posture //! from the [`Limits`]: the keepalive 15 s / 3 posture
//! (`reverse-runner.md` lesson 3 — OpenSSH //! (`reverse-runner.md` lesson 3, OpenSSH
//! `ServerAliveInterval=15`/`CountMax=3` parity), the wall clock //! `ServerAliveInterval=15`/`CountMax=3` parity), the wall clock
//! **always `Some`** (`inactivity_timeout` — the crate never //! **always `Some`** (`inactivity_timeout`, the crate never
//! constructs a session with no clock), and the window/packet/ //! constructs a session with no clock), and the window/packet/
//! channel-buffer knobs verbatim. Everything else stays russh's //! channel-buffer knobs verbatim. Everything else stays russh's
//! own default posture (the ADR-002 surface notes, mirrored from //! own default posture (the ADR-002 surface notes, mirrored from
//! [`crate::adapter`]'s server mapping: rekey `Limits` //! [`crate::adapter`]'s server mapping: rekey `Limits`
//! russh-enforced, preferred algorithms russh's — the ed25519-only //! russh-enforced, preferred algorithms russh's; the ed25519-only
//! posture holds by the loaded key set itself). No client-side //! posture holds by the loaded key set itself). No client-side
//! auth-attempt knob exists at this pin and none is needed: the //! auth-attempt knob exists at this pin and none is needed: the
//! family contract is exactly **one publickey attempt**. //! family contract is exactly **one publickey attempt**.
//! 3. **The policy call at `check_server_key`** (OQ-05) — the //! 3. **The policy call at `check_server_key`** (OQ-05), the
//! wave-1 [`HostKeyPolicy`](crate::HostKeyPolicy) seam consumed by //! wave-1 [`HostKeyPolicy`](crate::HostKeyPolicy) seam consumed by
//! the handler ([`crate::client_handler`]): the crate default //! the handler ([`crate::client_handler`]): the crate default
//! [`RejectAll`](crate::RejectAll) connects to nothing; a //! [`RejectAll`](crate::RejectAll) connects to nothing; a
@@ -34,23 +34,23 @@
//! Declines surface as [`DialError::HostKeyRejected`]. //! Declines surface as [`DialError::HostKeyRejected`].
//! 4. **Publickey-only authentication** (the family contract): //! 4. **Publickey-only authentication** (the family contract):
//! exactly one `authenticate_publickey` attempt from the loaded //! exactly one `authenticate_publickey` attempt from the loaded
//! key — no password, no keyboard-interactive, no cert, no retry, //! key (no password, no keyboard-interactive, no cert, no retry,
//! no fallback method (russh's handle methods for those are never //! no fallback method (russh's handle methods for those are never
//! exposed through [`SshConnection`]); a refusal is //! exposed through [`SshConnection`])); a refusal is
//! [`DialError::AuthRejected`] and the attempt is closed cleanly. //! [`DialError::AuthRejected`] and the attempt is closed cleanly.
//! //!
//! **The connection handle** ([`SshConnection`]) is the type //! **The connection handle** ([`SshConnection`]) is the type
//! everything else in the wave rides (the op rows open channels on //! everything else in the wave rides (the op rows open channels on
//! it; the `-R` leg installs the acceptor registry over it) — the //! it; the `-R` leg installs the acceptor registry over it), the
//! russh `client::Handle` wrapped with the crate's surface, the //! russh `client::Handle` wrapped with the crate's surface, the
//! policy, limits and endpoint labels carried. The connection handle's //! policy, limits and endpoint labels carried. The connection handle's
//! clean-close path is [`SshConnection::close`]; the listener handles' //! clean-close path is [`SshConnection::close`]; the listener handles'
//! surface is [`crate::client_accept`]'s. //! surface is [`crate::client_accept`]'s.
//! //!
//! ## Lifecycle posture (`w4-lifecycle` — the OQ-12 resolution in code) //! ## Lifecycle posture (`w4-lifecycle`, the OQ-12 resolution in code)
//! //!
//! The handle surface is **primitives, not policy** (reverse-runner //! The handle surface is **primitives, not policy** (reverse-runner
//! fact 4): reconnect-and-restore (rebinding persisted tunnel ports — //! fact 4): reconnect-and-restore (rebinding persisted tunnel ports,
//! the alkminer flow) is *assembly* composition over these //! the alkminer flow) is *assembly* composition over these
//! primitives; the crate provides bound ports, handles, cancellation, //! primitives; the crate provides bound ports, handles, cancellation,
//! clean close, and never owns a reconnect/restore policy. Two //! clean close, and never owns a reconnect/restore policy. Two
@@ -58,18 +58,18 @@
//! //!
//! - **Drop is never the clean path.** At this russh pin dropping the //! - **Drop is never the clean path.** At this russh pin dropping the
//! last wire-stack handle ends the runner and the transport goes //! last wire-stack handle ends the runner and the transport goes
//! down **abruptly** — the peer observes a broken session (its //! down **abruptly**: the peer observes a broken session (its
//! serving outcome is an error wire class), never the clean //! serving outcome is an error wire class), never the clean
//! disconnect message. So the surface never relies on drop timing //! disconnect message. So the surface never relies on drop timing
//! for clean state: only [`SshConnection::close`] sends //! for clean state: only [`SshConnection::close`] sends
//! `Disconnect::ByApplication` — the peer observes a clean //! `Disconnect::ByApplication`, the peer observes a clean
//! disconnect, no half-open channels (row-pinned through the //! disconnect, no half-open channels (row-pinned through the
//! server double, both directions of the boundary: abrupt-on-drop, //! server double, both directions of the boundary: abrupt-on-drop,
//! clean-on-close). //! clean-on-close).
//! And a cancellation is async — it cannot hide inside a sync //! And a cancellation is async; it cannot hide inside a sync
//! `Drop` hook, so no handle hides a `Drop` side effect: the //! `Drop` hook, so no handle hides a `Drop` side effect: the
//! listener handles' drop frees *nothing* on their wire state. //! listener handles' drop frees *nothing* on their wire state.
//! - **Every listener/handle is explicitly cancellable** — the `-R` //! - **Every listener/handle is explicitly cancellable**: the `-R`
//! listens return the [`ReverseListener`](crate::client_accept:: //! listens return the [`ReverseListener`](crate::client_accept::
//! ReverseListener) handles (the actually-bound endpoint carried; //! ReverseListener) handles (the actually-bound endpoint carried;
//! `cancel()` the wire-honest teardown), the connection handle //! `cancel()` the wire-honest teardown), the connection handle
@@ -84,7 +84,7 @@
//! [`russh_client_config`] (the bounded-knob mapping, asserted //! [`russh_client_config`] (the bounded-knob mapping, asserted
//! verbatim) and [`ClientHandler`](crate::client_handler::ClientHandler) //! verbatim) and [`ClientHandler`](crate::client_handler::ClientHandler)
//! (the handler skeleton; `client_handler.rs`). The wire vehicle is //! (the handler skeleton; `client_handler.rs`). The wire vehicle is
//! the in-process fake `sshd` in `tests/common/sshd_double.rs` — the //! the in-process fake `sshd` in `tests/common/sshd_double.rs`: the
//! client direction dials vanilla servers, so the loopback harness //! client direction dials vanilla servers, so the loopback harness
//! (the *server*-direction vehicle) only hosts the crate-to-crate //! (the *server*-direction vehicle) only hosts the crate-to-crate
//! smoke (see the task's smoke-posture record). //! smoke (see the task's smoke-posture record).
@@ -102,7 +102,7 @@ use crate::client_handler::ClientHandler;
use crate::config::{DialConfig, Limits}; use crate::config::{DialConfig, Limits};
use crate::error::DialError; use crate::error::DialError;
/// The authenticated dial — the client direction's entry point /// The authenticated dial, the client direction's entry point
/// (`client.md` §The dial surface): see the module docs for the /// (`client.md` §The dial surface): see the module docs for the
/// four-step sequence and their fail-closed outcomes. /// four-step sequence and their fail-closed outcomes.
pub async fn dial(config: DialConfig) -> Result<SshConnection, DialError> { pub async fn dial(config: DialConfig) -> Result<SshConnection, DialError> {
@@ -149,7 +149,7 @@ pub async fn dial(config: DialConfig) -> Result<SshConnection, DialError> {
}) })
} }
/// Parses the dial address into its endpoint labels — the /// Parses the dial address into its endpoint labels, the
/// [`PresentedHostKey`](crate::host_key::PresentedHostKey)'s /// [`PresentedHostKey`](crate::host_key::PresentedHostKey)'s
/// `host`/`port` decision context. Malformed targets refuse before /// `host`/`port` decision context. Malformed targets refuse before
/// any connection ([`DialError::Address`]): plain `host:port` and the /// any connection ([`DialError::Address`]): plain `host:port` and the
@@ -177,7 +177,7 @@ fn parse_dial_address(address: &str) -> Result<(String, u16), DialError> {
Ok((host.to_string(), port)) Ok((host.to_string(), port))
} }
/// The [`Limits`] → russh client `Config` mapping — the bounded knobs /// The [`Limits`] → russh client `Config` mapping: the bounded knobs
/// verbatim, the wall clock always on, everything else at russh's own /// verbatim, the wall clock always on, everything else at russh's own
/// default posture (see the module docs). The mapping is the dial /// default posture (see the module docs). The mapping is the dial
/// task's `bounded-config wiring observable`; /// task's `bounded-config wiring observable`;
@@ -199,15 +199,15 @@ pub fn russh_client_config(limits: &Limits) -> russh::client::Config {
build_client_config(limits) build_client_config(limits)
} }
/// The authenticated connection handle — the type every later /// The authenticated connection handle, the type every later
/// wave-4 op rides (`client.md`: "the authenticated connection /// wave-4 op rides (`client.md`: "the authenticated connection
/// handle"): the russh `client::Handle` wrapped with the crate's /// handle"): the russh `client::Handle` wrapped with the crate's
/// surface. The remote end is authenticated (publickey-only, single /// surface. The remote end is authenticated (publickey-only, single
/// attempt); the dial key is gone (its lifetime is the dial's — /// attempt); the dial key is gone (its lifetime is the dial's;
/// everything after rides the wire, never the key). /// everything after rides the wire, never the key).
/// ///
/// The russh handle, budgets and registry ride one shared inner /// The russh handle, budgets and registry ride one shared inner
/// (behind the `Arc`) — the sharing the `client_accept` module's /// (behind the `Arc`), the sharing the `client_accept` module's
/// listener handles need for their independent `cancel()` calls; the /// listener handles need for their independent `cancel()` calls; the
/// wire-visible teardowns stay explicit calls on top of that shared /// wire-visible teardowns stay explicit calls on top of that shared
/// stack (the module docs' §Lifecycle posture: drop is never a /// stack (the module docs' §Lifecycle posture: drop is never a
@@ -216,13 +216,13 @@ pub struct SshConnection {
shared: Arc<ConnectionShared>, shared: Arc<ConnectionShared>,
} }
/// The connection handle's internals — the one behind the /// The connection handle's internals, the one behind the
/// [`SshConnection`]'s `Arc`, every field private to the crate: the /// [`SshConnection`]'s `Arc`, every field private to the crate: the
/// wire surface is the wrapped methods, never the russh stack itself /// wire surface is the wrapped methods, never the russh stack itself
/// (`serve.rs`'s wrapped-surface pattern mirrored client-side). /// (`serve.rs`'s wrapped-surface pattern mirrored client-side).
/// ///
/// The arc's meaning for the lifecycle boundary: the russh stack /// The arc's meaning for the lifecycle boundary: the russh stack
/// (the runner) lives while *any* handle holds the arc — the /// (the runner) lives while *any* handle holds the arc; the
/// connection handle's drop is the abrupt teardown only when it is /// connection handle's drop is the abrupt teardown only when it is
/// the last holder (row-pinned), and a live `ReverseListener` (its /// the last holder (row-pinned), and a live `ReverseListener` (its
/// `cancel()` composes on this stack) keeps the wire serving until /// `cancel()` composes on this stack) keeps the wire serving until
@@ -231,18 +231,18 @@ pub struct SshConnection {
pub(crate) struct ConnectionShared { pub(crate) struct ConnectionShared {
/// The russh handle the connection and the listener handles /// The russh handle the connection and the listener handles
/// compose into (its own request methods take `&self` at this /// compose into (its own request methods take `&self` at this
/// pin — the shared arc is the concurrent-sharing shape). /// pin, the shared arc is the concurrent-sharing shape).
pub(crate) handle: russh::client::Handle<ClientHandler>, pub(crate) handle: russh::client::Handle<ClientHandler>,
pub(crate) limits: Limits, pub(crate) limits: Limits,
pub(crate) address: String, pub(crate) address: String,
pub(crate) username: String, pub(crate) username: String,
/// The `-R` leg's acceptor registry (the dial constructs it empty; /// The `-R` leg's acceptor registry (the dial constructs it empty;
/// the assembly installs/uninstalls across the live connection — /// the assembly installs/uninstalls across the live connection;
/// see [`SshConnection::acceptors`] and /// see [`SshConnection::acceptors`] and
/// [`crate::client_accept`]`'s recorded decisions. /// [`crate::client_accept`]`'s recorded decisions.
#[cfg(feature = "tunnel")] #[cfg(feature = "tunnel")]
pub(crate) acceptors: Arc<AcceptorRegistry>, pub(crate) acceptors: Arc<AcceptorRegistry>,
/// The reverse-listen count's dedicated bound — admitted at the /// The reverse-listen count's dedicated bound, admitted at the
/// listen rows, released at the confirmed cancels and the refused/ /// listen rows, released at the confirmed cancels and the refused/
/// wire-error paths (the cap relation recorded in /// wire-error paths (the cap relation recorded in
/// [`crate::client_accept`]; [`crate::channel_cap`]'s mutex makes /// [`crate::client_accept`]; [`crate::channel_cap`]'s mutex makes
@@ -271,7 +271,7 @@ impl SshConnection {
/// The `-R` leg's acceptor registry (the /// The `-R` leg's acceptor registry (the
/// `w4-reverse-listen` surface, `client_accept`'s module docs /// `w4-reverse-listen` surface, `client_accept`'s module docs
/// carry the recorded decisions) — install/uninstall the /// carry the recorded decisions); install/uninstall the
/// per-type acceptors across the live connection (the /// per-type acceptors across the live connection (the
/// late-install dynamic). The dial constructs it empty: nothing /// late-install dynamic). The dial constructs it empty: nothing
/// server-initiated is served until a slot holds, fail-closed at /// server-initiated is served until a slot holds, fail-closed at
@@ -281,14 +281,14 @@ impl SshConnection {
&self.shared.acceptors &self.shared.acceptors
} }
/// The reverse-listen count's shared handle — the op rows /// The reverse-listen count's shared handle, which the op rows
/// ([`crate::client_accept`]) read it; private to the crate. /// ([`crate::client_accept`]) read; private to the crate.
#[cfg(feature = "tunnel")] #[cfg(feature = "tunnel")]
pub(crate) fn listener_budget(&self) -> &ChannelCap { pub(crate) fn listener_budget(&self) -> &ChannelCap {
&self.shared.listener_budget &self.shared.listener_budget
} }
/// The shared inner — the listener handles' compose-into point /// The shared inner, the listener handles' compose-into point
/// (their cancels run against the same russh stack); private to /// (their cancels run against the same russh stack); private to
/// the crate. /// the crate.
#[cfg(feature = "tunnel")] #[cfg(feature = "tunnel")]
@@ -296,7 +296,7 @@ impl SshConnection {
&self.shared &self.shared
} }
/// The russh handle the op rows compose into (`w4-op-rows` — the /// The russh handle the op rows compose into (`w4-op-rows`; the
/// method bodies live in [`crate::client_ops`], a sibling module, /// method bodies live in [`crate::client_ops`], a sibling module,
/// so the field stays private to this module). /// so the field stays private to this module).
#[cfg(any(feature = "session", feature = "git", feature = "tunnel"))] #[cfg(any(feature = "session", feature = "git", feature = "tunnel"))]
@@ -306,7 +306,7 @@ impl SshConnection {
&self.shared.handle &self.shared.handle
} }
/// The dial's bounded-resource budget, verbatim — the op rows /// The dial's bounded-resource budget, verbatim; the op rows
/// read the knobs they consume (the exec-command length cap /// read the knobs they consume (the exec-command length cap
/// drives the command-carrying rows' client-mirror gate). /// drives the command-carrying rows' client-mirror gate).
#[cfg(any(feature = "session", feature = "git"))] #[cfg(any(feature = "session", feature = "git"))]
@@ -320,12 +320,12 @@ impl SshConnection {
} }
/// The explicit clean-close path (the lifecycle primitive, /// The explicit clean-close path (the lifecycle primitive,
/// [`russh::Disconnect::ByApplication`] — the honest close): the /// [`russh::Disconnect::ByApplication`], the honest close): the
/// peer observes a clean disconnect, no half-open channels — /// peer observes a clean disconnect, no half-open channels;
/// every open channel is torn down with the session (row-pinned /// every open channel is torn down with the session (row-pinned
/// through the server double). **Drop is not this path** (the /// through the server double). **Drop is not this path** (the
/// module docs' §Lifecycle posture): a dropped handle releases /// module docs' §Lifecycle posture): a dropped handle releases
/// nothing on the wire — the connection outlives the handle /// nothing on the wire; the connection outlives the handle
/// until the peer or the wall clock ends it. /// until the peer or the wall clock ends it.
pub async fn close(self) { pub async fn close(self) {
let _ = self let _ = self
@@ -373,7 +373,7 @@ mod tests {
} }
/// - *Contract stamp:* `client.md` §Correctness facts (the /// - *Contract stamp:* `client.md` §Correctness facts (the
/// keepalive posture) + `overview.md` §Bounded-resources table — /// keepalive posture) + `overview.md` §Bounded-resources table;
/// the defaults land in the russh mapping verbatim. /// the defaults land in the russh mapping verbatim.
#[test] #[test]
fn bounded_config_mapping_carries_the_default_postures() { fn bounded_config_mapping_carries_the_default_postures() {
@@ -385,7 +385,7 @@ mod tests {
assert_eq!( assert_eq!(
config.inactivity_timeout, config.inactivity_timeout,
Some(crate::config::DEFAULT_INACTIVITY_TIMEOUT), Some(crate::config::DEFAULT_INACTIVITY_TIMEOUT),
"the wall clock is always set — no unbounded session" "the wall clock is always set, never an unbounded session"
); );
assert_eq!( assert_eq!(
config.keepalive_interval, config.keepalive_interval,
+85 -85
View File
@@ -1,28 +1,28 @@
//! The `-R` leg's client half (`w4-reverse-listen` — `client.md` //! The `-R` leg's client half (`w4-reverse-listen`, `client.md`
//! §op rows row 4 + §The acceptor): the hub's reverse direction in //! §op rows row 4 + §The acceptor): the hub's reverse direction in
//! family types. Two surfaces, the acceptor registry first: //! family types. Two surfaces, the acceptor registry first:
//! //!
//! 1. **The forward request rows** — [`SshConnection::reverse_listen`] //! 1. **The forward request rows**: [`SshConnection::reverse_listen`]
//! / [`SshConnection::reverse_listen_cancel`] / the unix pair //! / [`SshConnection::reverse_listen_cancel`] / the unix pair
//! (`reverse_listen_unix` / `reverse_listen_unix_cancel`): the //! (`reverse_listen_unix` / `reverse_listen_unix_cancel`): the
//! vanilla-server listen requests, the lifecycle primitives //! vanilla-server listen requests, the lifecycle primitives
//! (`client.md` §Lifecycle). The listens return the //! (`client.md` §Lifecycle). The listens return the
//! **[`ReverseListener`] handles** (`w4-lifecycle` — the handles' //! **[`ReverseListener`] handles** (`w4-lifecycle`, the handles'
//! surface: the actually-bound endpoint carried, the explicit //! surface: the actually-bound endpoint carried, the explicit
//! `cancel()`); the stateless wire cancel forms ride beside them //! `cancel()`); the stateless wire cancel forms ride beside them
//! (sshd's global-request cancels mirrored 1:1). //! (sshd's global-request cancels mirrored 1:1).
//! 2. **The acceptor registry** ([`AcceptorRegistry`]) — the //! 2. **The acceptor registry** ([`AcceptorRegistry`]): the
//! assembly-mutable, per-channel-type registry the dial surface's //! assembly-mutable, per-channel-type registry the dial surface's
//! russh handler routes **server-initiated `forwarded-*` opens** //! russh handler routes **server-initiated `forwarded-*` opens**
//! through. Served only under a registered acceptor for that type //! through. Served only under a registered acceptor for that type
//! and under the `tunnel` feature; unregistered/unfeatured accepted //! and under the `tunnel` feature; unregistered/unfeatured accepted
//! opens **decline cleanly** — never russh's accept-and-drop //! opens **decline cleanly**, never russh's accept-and-drop
//! default (ADR-003 §4, the client-side fail-closed acceptor; the //! default (ADR-003 §4, the client-side fail-closed acceptor; the
//! reverse-runner fact 1 disposition is ours). //! reverse-runner fact 1 disposition is ours).
//! //!
//! ## The recorded decisions (the task's decide + record items) //! ## The recorded decisions (the task's decide + record items)
//! //!
//! - **Registry shape: per-type fixed slots, not a typed map** — the //! - **Registry shape: per-type fixed slots, not a typed map**: the
//! client direction's registry-servable type set is closed and tiny //! client direction's registry-servable type set is closed and tiny
//! (exactly the `-R` data leg's two: `forwarded-tcpip` / //! (exactly the `-R` data leg's two: `forwarded-tcpip` /
//! `forwarded-streamlocal`, both under `tunnel`); one trait per type //! `forwarded-streamlocal`, both under `tunnel`); one trait per type
@@ -35,70 +35,70 @@
//! `ForwardedAcceptor` vocabulary is grown, not forked: the payload //! `ForwardedAcceptor` vocabulary is grown, not forked: the payload
//! fields are the same shapes (the open's originator fields + the //! fields are the same shapes (the open's originator fields + the
//! channel duplex), the duplex now family-typed //! channel duplex), the duplex now family-typed
//! ([`TunnelChannel`] — the `-L` rows' byte //! ([`TunnelChannel`], the `-L` rows' byte
//! duplex, one type both legs ride). //! duplex, one type both legs ride).
//! - **Install is refuse-never-replace** ([`AcceptorRegistry:: //! - **Install is refuse-never-replace** ([`AcceptorRegistry::
//! install_tcpip`]'s held-slot error): the same posture recorded //! install_tcpip`]'s held-slot error): the same posture recorded
//! server-side on the `ListenRouter` at `w4-router-collision` — //! server-side on the `ListenRouter` at `w4-router-collision`:
//! a same-slot second install is an assembly program error, declined //! a same-slot second install is an assembly program error, declined
//! never a quiet displacement of a live acceptor. Uninstall is //! never a quiet displacement of a live acceptor. Uninstall is
//! explicit, 1:1 with the install. //! explicit, 1:1 with the install.
//! - **Late-install across a live connection** (the alkminer //! - **Late-install across a live connection** (the alkminer
//! `install_forward_sink` dynamic — reverse-runner fact 2): the //! `install_forward_sink` dynamic, reverse-runner fact 2): the
//! registry rides the connected [`SshConnection`] //! registry rides the connected [`SshConnection`]
//! ([`SshConnection::acceptors`]) — the dial constructs it empty, //! ([`SshConnection::acceptors`]): the dial constructs it empty,
//! the assembly installs/uninstalls whenever it decides. The open //! the assembly installs/uninstalls whenever it decides. The open
//! hooks read the slot **at arrival time**, fail-closed: an open //! hooks read the slot **at arrival time**, fail-closed: an open
//! arriving before its acceptor installs (or after an uninstall) //! arriving before its acceptor installs (or after an uninstall)
//! is **declined at arrival, never buffered** — no unclaimed-open //! is **declined at arrival, never buffered**; no unclaimed-open
//! queue exists anywhere in this module's posture (an unclaimed //! queue exists anywhere in this module's posture (an unclaimed
//! open's bytes would have to be buffered unboundedly to accept- //! open's bytes would have to be buffered unboundedly to accept-
//! then-route; the decline is the clean, bounded answer). //! then-route; the decline is the clean, bounded answer).
//! - **The listen rows gate on the registered acceptor** — the //! - **The listen rows gate on the registered acceptor**, the
//! *issuing*-side disposition: a listen whose data leg cannot serve //! *issuing*-side disposition: a listen whose data leg cannot serve
//! is a half-capability (the peer stays bound, every arrival //! is a half-capability (the peer stays bound, every arrival
//! declines), so a `reverse_listen` with no registered acceptor for //! declines), so a `reverse_listen` with no registered acceptor for
//! the type answers //! the type answers
//! [`OpError::NoAcceptor`](crate::OpError) **before any wire op** //! [`OpError::NoAcceptor`](crate::OpError) **before any wire op**
//! (install-first is the recorded order; the rows never buffer an //! (install-first is the recorded order; the rows never buffer an
//! outstanding listen against a later install — re-issuing is the //! outstanding listen against a later install; re-issuing is the
//! assembly's retry, the listen rows are stateless global requests). //! assembly's retry, the listen rows are stateless global requests).
//! - **Bounded bookkeeping (AGENTS.md convention 9) — the cap //! - **Bounded bookkeeping (AGENTS.md convention 9): the cap
//! relation** (the server-side listener-counter posture as the //! relation** (the server-side listener-counter posture as the
//! template): the registry is bounded by construction (two fixed //! template): the registry is bounded by construction (two fixed
//! slots). The per-connection **listener count** is a dedicated //! slots). The per-connection **listener count** is a dedicated
//! in-crate counter admitting at the listen rows and releasing at //! in-crate counter admitting at the listen rows and releasing at
//! the confirmed cancels and the failed/wire-error paths — bounded //! the confirmed cancels and the failed/wire-error paths, bounded
//! against the *same* `Limits::max_concurrent_channels` ceiling the //! against the *same* `Limits::max_concurrent_channels` ceiling the
//! server direction's listener counter uses (independent counts, one //! server direction's listener counter uses (independent counts, one
//! ceiling — never a map: the outstanding listens live at the peer; //! ceiling, never a map: the outstanding listens live at the peer;
//! the counter exists so a dial cannot mint an unbounded listener //! the counter exists so a dial cannot mint an unbounded listener
//! set the registry would have to serve). The per-connection //! set the registry would have to serve). The per-connection
//! **accepted-open handoff count** is the second dedicated counter //! **accepted-open handoff count** is the second dedicated counter
//! against the same ceiling (admitted in the dial handler's open //! against the same ceiling (admitted in the dial handler's open
//! hook, accepted-with-the-count-held, released when the handoff //! hook, accepted-with-the-count-held, released when the handoff
//! task ends — the acceptor's `accept` completing *or the task //! task ends: the acceptor's `accept` completing *or the task
//! unwinding* ([`HandoffCapGuard`]: a panicking assembly acceptor //! unwinding* ([`HandoffCapGuard`]: a panicking assembly acceptor
//! can never strand a held slot) — no accepted-open ledger anywhere //! can never strand a held slot); no accepted-open ledger anywhere
//! else: the pump is the acceptor's from the handoff on, the //! else: the pump is the acceptor's from the handoff on, the
//! channel's transport bounds are russh's windows, and the slot read //! channel's transport bounds are russh's windows, and the slot read
//! is the arrival admission). //! is the arrival admission).
//! - **The listener handles (`w4-lifecycle` — the handles' //! - **The listener handles (`w4-lifecycle`, the handles'
//! surface)**: the listen rows return **[`ReverseListener`]** //! surface)**: the listen rows return **[`ReverseListener`]**
//! handles — the endpoint (the actually-bound values, the cancel //! handles: the endpoint (the actually-bound values, the cancel
//! match keys) carried, the explicit `cancel()` the wire-honest //! match keys) carried, the explicit `cancel()` the wire-honest
//! teardown. Decisions recorded: //! teardown. Decisions recorded:
//! (a) **no `Drop` hides a wire op** — a cancellation is async and //! (a) **no `Drop` hides a wire op**: a cancellation is async and
//! cannot run in a sync `Drop`, so no `Drop` hook exists: the //! cannot run in a sync `Drop`, so no `Drop` hook exists: the
//! listen **outlives a dropped handle until an explicit cancel or //! listen **outlives a dropped handle until an explicit cancel or
//! the connection's end** (no drop-timing-only wire state — //! the connection's end** (no drop-timing-only wire state,
//! row-pinned); //! row-pinned);
//! (b) the handle **shares the connection's inner stack** (the //! (b) the handle **shares the connection's inner stack** (the
//! `SshConnection`'s `Arc`-held internals — the russh request //! `SshConnection`'s `Arc`-held internals; the russh request
//! methods take `&self` at this pin), so the handle's cancel is the //! methods take `&self` at this pin), so the handle's cancel is the
//! same wire op the stateless forms issue; //! same wire op the stateless forms issue;
//! (c) the **stateless cancel forms ride beside** the handles //! (c) the **stateless cancel forms ride beside** the handles
//! (sshd's global-request cancels are stateless — a cancel by the //! (sshd's global-request cancels are stateless: a cancel by the
//! raw strings works from anywhere); every form releases the //! raw strings works from anywhere); every form releases the
//! listener count exactly once on a confirmed cancel (the wire //! listener count exactly once on a confirmed cancel (the wire
//! state machine governs: the second cancel is the unknown-listener //! state machine governs: the second cancel is the unknown-listener
@@ -107,37 +107,37 @@
//! *listen* rows (`reverse_listen_unix` / `reverse_listen_unix_cancel`) //! *listen* rows (`reverse_listen_unix` / `reverse_listen_unix_cancel`)
//! compile everywhere and //! compile everywhere and
//! decline off-Unix before any wire op (the bind is the //! decline off-Unix before any wire op (the bind is the
//! platform-bound operation — the door puts nothing on the wire it //! platform-bound operation; the door puts nothing on the wire it
//! cannot track). The `forwarded-streamlocal` *open* hook is //! cannot track). The `forwarded-streamlocal` *open* hook is
//! universal: ingestion is platform-neutral byte pumping — the //! universal: ingestion is platform-neutral byte pumping; the
//! unix-socket leg's socket is the peer-side's. //! unix-socket leg's socket is the peer-side's.
//! - **`OpError::ListenRefused`** — the peer's `RequestDenied` on //! - **`OpError::ListenRefused`**: the peer's `RequestDenied` on
//! every form's decline (a denied listen, a cancel of an unknown //! every form's decline (a denied listen, a cancel of an unknown
//! listener) — channel-scoped honesty: the connection stays usable //! listener), channel-scoped honesty: the connection stays usable
//! (the `From` mapping's recorded producer, `error.rs`). //! (the `From` mapping's recorded producer, `error.rs`).
//! //!
//! ## The data leg //! ## The data leg
//! //!
//! Per accepted open: the hook accepts the channel and hands the //! Per accepted open: the hook accepts the channel and hands the
//! assembly the open — the [`TunnelChannel`] //! assembly the open, the [`TunnelChannel`]
//! duplex + the open's echo/originator fields (the payload types //! duplex + the open's echo/originator fields (the payload types
//! below) — on a spawned task (the spawn-and-return rule's client //! below), on a spawned task (the spawn-and-return rule's client
//! mirror: the hook runs in the packet loop and returns fast). The //! mirror: the hook runs in the packet loop and returns fast). The
//! pump is the acceptor's from the handoff — the family handler shape //! pump is the acceptor's from the handoff, the family handler shape
//! (alktunnels' `ForwardedHandler` recipe, POC-3 §C.4); dropping the //! (alktunnels' `ForwardedHandler` recipe, POC-3 §C.4); dropping the
//! duplex is wire-clean (russh closes the channel). //! duplex is wire-clean (russh closes the channel).
//! //!
//! ## Test surface //! ## Test surface
//! //!
//! The wire vehicle is the fake `sshd` double grown with the //! The wire vehicle is the fake `sshd` double grown with the
//! server-initiated-forward surfaces (`tests/common/sshd_double.rs`) — //! server-initiated-forward surfaces (`tests/common/sshd_double.rs`);
//! the double binds **real** sockets for its script accepts, opens the //! the double binds **real** sockets for its script accepts, opens the
//! forwarded channels toward the client per accepted connection, and //! forwarded channels toward the client per accepted connection, and
//! pumps them (a real `ssh -R`-shaped round trip in-process, the task's //! pumps them (a real `ssh -R`-shaped round trip in-process, the task's
//! recorded smoke posture). The full-circle alkssh-client ↔ //! recorded smoke posture). The full-circle alkssh-client ↔
//! alkssh-server-direction smoke (both `-R` legs through the alkcall //! alkssh-server-direction smoke (both `-R` legs through the alkcall
//! fabric, the composition the assembly makes) rides the fabric //! fabric, the composition the assembly makes) rides the fabric
//! doubles in `tests/tunnel_mapping.rs` — the same doubles the //! doubles in `tests/tunnel_mapping.rs`, the same doubles the
//! server-side `-R` rows use; real-`sshd` `-R` is wave-5 e2e //! server-side `-R` rows use; real-`sshd` `-R` is wave-5 e2e
//! territory, never silently dropped. //! territory, never silently dropped.
//! //!
@@ -163,24 +163,24 @@ use crate::client::SshConnection;
use crate::error::OpError; use crate::error::OpError;
use crate::observe; use crate::observe;
/// An accepted server-initiated `forwarded-tcpip` open — the acceptor /// An accepted server-initiated `forwarded-tcpip` open: the acceptor
/// registry's payload for the /// registry's payload for the
/// [`TcpipForwardedAcceptor::accept`] call: the open's echo fields /// [`TcpipForwardedAcceptor::accept`] call: the open's echo fields
/// (the listen endpoint the arrival belongs to — the assembly's /// (the listen endpoint the arrival belongs to, the assembly's
/// routing key; the OpenSSH matcher parity verbatim) + the originator /// routing key; the OpenSSH matcher parity verbatim) + the originator
/// (the arrival's peer, as the wire carried it) + the channel duplex. /// (the arrival's peer, as the wire carried it) + the channel duplex.
pub struct ForwardedTcpip { pub struct ForwardedTcpip {
/// The connected address the open echoed — the `reverse_listen` /// The connected address the open echoed, the `reverse_listen`
/// wire string (sshd parity). /// wire string (sshd parity).
pub connected_address: String, pub connected_address: String,
/// The actually-bound port of the minted listener — for a /// The actually-bound port of the minted listener; for a
/// `port = 0` mint this is the server-picked number, never `0`. /// `port = 0` mint this is the server-picked number, never `0`.
pub connected_port: u32, pub connected_port: u32,
/// The arrival's peer address (the wire originator). /// The arrival's peer address (the wire originator).
pub originator_address: String, pub originator_address: String,
/// The arrival's peer port (the wire originator). /// The arrival's peer port (the wire originator).
pub originator_port: u32, pub originator_port: u32,
/// The accepted channel's byte duplex — the acceptor owns the /// The accepted channel's byte duplex; the acceptor owns the
/// pump from the handoff on (dropping it is wire-clean). /// pump from the handoff on (dropping it is wire-clean).
pub stream: TunnelChannel, pub stream: TunnelChannel,
} }
@@ -196,14 +196,14 @@ impl std::fmt::Debug for ForwardedTcpip {
} }
} }
/// An accepted server-initiated `forwarded-streamlocal` open — the /// An accepted server-initiated `forwarded-streamlocal` open: the
/// streamlocal acceptor's payload (the echoed socket path + the /// streamlocal acceptor's payload (the echoed socket path + the
/// channel duplex). /// channel duplex).
pub struct ForwardedStreamlocal { pub struct ForwardedStreamlocal {
/// The socket path the open echoed — the `reverse_listen_unix` /// The socket path the open echoed, the `reverse_listen_unix`
/// wire string (sshd parity). /// wire string (sshd parity).
pub socket_path: String, pub socket_path: String,
/// The accepted channel's byte duplex — the acceptor owns the /// The accepted channel's byte duplex; the acceptor owns the
/// pump from the handoff on (dropping it is wire-clean). /// pump from the handoff on (dropping it is wire-clean).
pub stream: TunnelChannel, pub stream: TunnelChannel,
} }
@@ -216,34 +216,34 @@ impl std::fmt::Debug for ForwardedStreamlocal {
} }
} }
/// The `forwarded-tcpip` acceptor — the registry's per-type slot /// The `forwarded-tcpip` acceptor: the registry's per-type slot
/// trait. `accept` runs on the spawned handoff task (never the /// trait. `accept` runs on the spawned handoff task (never the
/// packet loop): the assembly's pump/home of the open lives inside /// packet loop): the assembly's pump/home of the open lives inside
/// it; the crate releases its handoff counter when the handoff task /// it; the crate releases its handoff counter when the handoff task
/// ends — the call returning *or the task unwinding* /// ends (the call returning *or the task unwinding*
/// (`HandoffCapGuard`) — so the family shape (hand off to a queue, /// `HandoffCapGuard`), so the family shape (hand off to a queue,
/// return) keeps the counter's meaning "open accepted, handoff in /// return) keeps the counter's meaning "open accepted, handoff in
/// flight". /// flight".
#[async_trait] #[async_trait]
pub trait TcpipForwardedAcceptor: Send + Sync { pub trait TcpipForwardedAcceptor: Send + Sync {
/// Serves one accepted open. Never invoked for an unregistered /// Serves one accepted open. Never invoked for an unregistered
/// type (the registry gate is first); the open is already accepted /// type (the registry gate is first); the open is already accepted
/// on the wire — refusing one from here is a *data-plane* choice /// on the wire; refusing one from here is a *data-plane* choice
/// (drop the duplex; the channel closes on it), never a wire /// (drop the duplex; the channel closes on it), never a wire
/// open-decline. /// open-decline.
async fn accept(&self, open: ForwardedTcpip); async fn accept(&self, open: ForwardedTcpip);
} }
/// The `forwarded-streamlocal` acceptor — the unix slot's trait /// The `forwarded-streamlocal` acceptor: the unix slot's trait
/// (see [`TcpipForwardedAcceptor`] for the call-shape contract). /// (see [`TcpipForwardedAcceptor`] for the call-shape contract).
#[async_trait] #[async_trait]
pub trait StreamlocalForwardedAcceptor: Send + Sync { pub trait StreamlocalForwardedAcceptor: Send + Sync {
/// Serves one accepted open (platform-neutral pumping — see the /// Serves one accepted open (platform-neutral pumping; see the
/// module docs' unix-gate record). /// module docs' unix-gate record).
async fn accept(&self, open: ForwardedStreamlocal); async fn accept(&self, open: ForwardedStreamlocal);
} }
/// The held-slot refusal (`AcceptorRegistry::install_*`) — the /// The held-slot refusal (`AcceptorRegistry::install_*`): the
/// refuse-never-replace posture (the server-side `ListenRouter`'s /// refuse-never-replace posture (the server-side `ListenRouter`'s
/// recorded sibling): the slot already holds a live acceptor; a second /// recorded sibling): the slot already holds a live acceptor; a second
/// install is the assembly's program error, declined, never a quiet /// install is the assembly's program error, declined, never a quiet
@@ -252,7 +252,7 @@ pub trait StreamlocalForwardedAcceptor: Send + Sync {
#[error("an acceptor is already registered for this channel type")] #[error("an acceptor is already registered for this channel type")]
pub struct AcceptorHeld; pub struct AcceptorHeld;
/// The assembly-mutable, per-channel-type acceptor registry — the /// The assembly-mutable, per-channel-type acceptor registry: the
/// `-R` data leg's fail-closed gate (see the module docs for the /// `-R` data leg's fail-closed gate (see the module docs for the
/// recorded decisions). Constructed empty at the dial /// recorded decisions). Constructed empty at the dial
/// ([`SshConnection::acceptors`], the live-connection accessor); /// ([`SshConnection::acceptors`], the live-connection accessor);
@@ -263,7 +263,7 @@ pub struct AcceptorRegistry {
} }
impl AcceptorRegistry { impl AcceptorRegistry {
/// The empty registry — the dial's construct (nothing is served /// The empty registry, the dial's construct (nothing is served
/// until an acceptor installs; ADR-003 §4's fail-closed default). /// until an acceptor installs; ADR-003 §4's fail-closed default).
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@@ -273,7 +273,7 @@ impl AcceptorRegistry {
} }
/// Installs the `forwarded-tcpip` acceptor. Refuse-never-replace: /// Installs the `forwarded-tcpip` acceptor. Refuse-never-replace:
/// a held slot declines ([`AcceptorHeld`]) — uninstall first if a /// a held slot declines ([`AcceptorHeld`]); uninstall first if a
/// replacement is meant (the late-install toggle's other half). /// replacement is meant (the late-install toggle's other half).
pub fn install_tcpip( pub fn install_tcpip(
&self, &self,
@@ -287,7 +287,7 @@ impl AcceptorRegistry {
Ok(()) Ok(())
} }
/// Uninstalls the `forwarded-tcpip` acceptor — `true` when one was /// Uninstalls the `forwarded-tcpip` acceptor: `true` when one was
/// held (the toggle's honest report). In-flight opens already /// held (the toggle's honest report). In-flight opens already
/// accepted keep pumping (their handoff happened); arrivals after /// accepted keep pumping (their handoff happened); arrivals after
/// this decline at arrival time (fail-closed, no buffering). /// this decline at arrival time (fail-closed, no buffering).
@@ -315,7 +315,7 @@ impl AcceptorRegistry {
self.streamlocal.lock().take().is_some() self.streamlocal.lock().take().is_some()
} }
/// The arrival-time slot reads (the open hooks' gates) — cloned /// The arrival-time slot reads (the open hooks' gates), cloned
/// out so the spawned handoff task owns its acceptor handle. /// out so the spawned handoff task owns its acceptor handle.
pub(crate) fn tcpip_acceptor(&self) -> Option<Arc<dyn TcpipForwardedAcceptor>> { pub(crate) fn tcpip_acceptor(&self) -> Option<Arc<dyn TcpipForwardedAcceptor>> {
self.tcpip.lock().clone() self.tcpip.lock().clone()
@@ -345,7 +345,7 @@ impl std::fmt::Debug for AcceptorRegistry {
// The server-initiated opens' handoff (the dial handler's routed paths) // The server-initiated opens' handoff (the dial handler's routed paths)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// The handoff counter's panic-safe release — the accepted open's count /// The handoff counter's panic-safe release: the accepted open's count
/// releases exactly at the handoff task's end, on *completion or /// releases exactly at the handoff task's end, on *completion or
/// unwind*: a panicking assembly acceptor (or any panic in the handoff /// unwind*: a panicking assembly acceptor (or any panic in the handoff
/// body) can never strand a held slot past the task. Saturating under /// body) can never strand a held slot past the task. Saturating under
@@ -358,14 +358,14 @@ impl Drop for HandoffCapGuard {
} }
} }
/// The accepted `forwarded-tcpip` open's handoff task — the /// The accepted `forwarded-tcpip` open's handoff task: the
/// spawn-and-return rule's client mirror (ADR-006 decision 4; the /// spawn-and-return rule's client mirror (ADR-006 decision 4; the
/// hook runs in the packet loop and returns fast): accepts the open on /// hook runs in the packet loop and returns fast): accepts the open on
/// the wire, converts the channel to the family duplex, delivers the /// the wire, converts the channel to the family duplex, delivers the
/// payload to the assembly's acceptor, and releases the handoff /// payload to the assembly's acceptor, and releases the handoff
/// counter when the handoff task ends — the acceptor's `accept` /// counter when the handoff task ends: the acceptor's `accept`
/// completing *or the task unwinding* ([`HandoffCapGuard`]; the /// completing *or the task unwinding* ([`HandoffCapGuard`]; the
/// recorded release site — the count's meaning is "accepted, handoff /// recorded release site; the count's meaning is "accepted, handoff
/// in flight"). /// in flight").
#[cfg_attr(feature = "tunnel", allow(clippy::too_many_arguments))] #[cfg_attr(feature = "tunnel", allow(clippy::too_many_arguments))]
pub(crate) async fn handoff_forwarded_tcpip( pub(crate) async fn handoff_forwarded_tcpip(
@@ -396,7 +396,7 @@ pub(crate) async fn handoff_forwarded_tcpip(
observe::serving_path_ended("forwarded-tcpip", channel_number, None); observe::serving_path_ended("forwarded-tcpip", channel_number, None);
} }
/// The accepted `forwarded-streamlocal` open's handoff task — the /// The accepted `forwarded-streamlocal` open's handoff task: the
/// unix slot's mirror of [`handoff_forwarded_tcpip`]. /// unix slot's mirror of [`handoff_forwarded_tcpip`].
pub(crate) async fn handoff_forwarded_streamlocal( pub(crate) async fn handoff_forwarded_streamlocal(
acceptor: Arc<dyn StreamlocalForwardedAcceptor>, acceptor: Arc<dyn StreamlocalForwardedAcceptor>,
@@ -421,10 +421,10 @@ pub(crate) async fn handoff_forwarded_streamlocal(
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// The listener handles (w4-lifecycle — the handles' surface) // The listener handles (w4-lifecycle, the handles' surface)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// The endpoint a reverse listener is bound to — what a /// The endpoint a reverse listener is bound to: what a
/// [`ReverseListener`] carries for its assembly-side use (the /// [`ReverseListener`] carries for its assembly-side use (the
/// alkminer restore flow: the *actually bound* values to persist and /// alkminer restore flow: the *actually bound* values to persist and
/// rebind) and the cancel forms' raw wire match key (sshd parity: a /// rebind) and the cancel forms' raw wire match key (sshd parity: a
@@ -439,8 +439,8 @@ pub enum ReverseEndpoint {
} }
impl ReverseEndpoint { impl ReverseEndpoint {
/// The actually-bound port — the tcp shape's; `None` for the unix /// The actually-bound port, the tcp shape's; `None` for the unix
/// shape (no port semantics — the cancel matches by the path). /// shape (no port semantics; the cancel matches by the path).
pub fn bound_port(&self) -> Option<u32> { pub fn bound_port(&self) -> Option<u32> {
match self { match self {
Self::Tcp { port, .. } => Some(*port), Self::Tcp { port, .. } => Some(*port),
@@ -449,14 +449,14 @@ impl ReverseEndpoint {
} }
} }
/// A returned forward listener — the `-R` leg's **listener handle** /// A returned forward listener: the `-R` leg's **listener handle**
/// (the lifecycle primitive: every listener explicitly cancellable): /// (the lifecycle primitive: every listener explicitly cancellable):
/// the bound endpoint carried (see [`ReverseEndpoint`]; the /// the bound endpoint carried (see [`ReverseEndpoint`]; the
/// assembly's own record for a restore/rebind policy — the crate /// assembly's own record for a restore/rebind policy; the crate
/// ships none, OQ-12), and the explicit `cancel()`. /// ships none, OQ-12), and the explicit `cancel()`.
/// ///
/// **Drop frees nothing on the wire** (the recorded decision in this /// **Drop frees nothing on the wire** (the recorded decision in this
/// module's docs): there is no `Drop` hook — a dropped handle leaves /// module's docs): there is no `Drop` hook, so a dropped handle leaves
/// the peer's listen serving until an explicit `cancel()` or the /// the peer's listen serving until an explicit `cancel()` or the
/// connection's end removes it. The handle does not borrow the /// connection's end removes it. The handle does not borrow the
/// connection: the cancel composes on the shared wire stack (the /// connection: the cancel composes on the shared wire stack (the
@@ -469,18 +469,18 @@ pub struct ReverseListener {
} }
impl ReverseListener { impl ReverseListener {
/// The bound endpoint — the actually-bound address/port or socket /// The bound endpoint, the actually-bound address/port or socket
/// path (never the requested `0` for a mint: the peer's answer is /// path (never the requested `0` for a mint: the peer's answer is
/// the surfaced number). /// the surfaced number).
pub fn endpoint(&self) -> &ReverseEndpoint { pub fn endpoint(&self) -> &ReverseEndpoint {
&self.endpoint &self.endpoint
} }
/// The explicit cancellation — the wire teardown: the peer /// The explicit cancellation, the wire teardown: the peer
/// removes the matching listen (the endpoint's raw wire strings, /// removes the matching listen (the endpoint's raw wire strings,
/// sshd parity; the accept loop ends, the bound port frees) and /// sshd parity; the accept loop ends, the bound port frees) and
/// the confirmed cancel releases the connection's listener count. /// the confirmed cancel releases the connection's listener count.
/// The peer's refusal (an unknown listener — e.g. the already- /// The peer's refusal (an unknown listener, e.g. the already-
/// cancelled, or a cancelled by the connection's end itself) is /// cancelled, or a cancelled by the connection's end itself) is
/// [`OpError::ListenRefused`](crate::OpError). The handle is /// [`OpError::ListenRefused`](crate::OpError). The handle is
/// consumed (one cancel through it); a later cancel by the same /// consumed (one cancel through it); a later cancel by the same
@@ -511,10 +511,10 @@ impl std::fmt::Debug for ReverseListener {
} }
} }
/// The tcp cancel's shared path — the stateless wire form and the /// The tcp cancel's shared path: the stateless wire form and the
/// listener handle's `cancel` compose into it: the wire op, then the /// listener handle's `cancel` compose into it: the wire op, then the
/// confirmed release (exactly once: a second cancel hits the wire's /// confirmed release (exactly once: a second cancel hits the wire's
/// unknown-listener refusal, no release — budget-safe by the wire's /// unknown-listener refusal, no release; budget-safe by the wire's
/// own state machine). /// own state machine).
async fn cancel_reverse_tcp( async fn cancel_reverse_tcp(
shared: &crate::client::ConnectionShared, shared: &crate::client::ConnectionShared,
@@ -531,7 +531,7 @@ async fn cancel_reverse_tcp(
} }
/// The unix cancel's shared path (the twin; the runtime-Unix gate /// The unix cancel's shared path (the twin; the runtime-Unix gate
/// answers before the wire — see the module docs' gate record). /// answers before the wire; see the module docs' gate record).
#[cfg(unix)] #[cfg(unix)]
async fn cancel_reverse_unix( async fn cancel_reverse_unix(
shared: &crate::client::ConnectionShared, shared: &crate::client::ConnectionShared,
@@ -552,28 +552,28 @@ async fn cancel_reverse_unix(
impl SshConnection { impl SshConnection {
/// The reverse-listen row (`-R`): asks the vanilla server to listen /// The reverse-listen row (`-R`): asks the vanilla server to listen
/// on (address, port) and returns the **actually-bound port** — /// on (address, port) and returns the **actually-bound port**;
/// for `port = 0` this is the peer-picked number (`client.md` /// for `port = 0` this is the peer-picked number (`client.md`
/// §Lifecycle: the bound-port primitive, the port-0 mint); for an /// §Lifecycle: the bound-port primitive, the port-0 mint); for an
/// explicit port the wire's success reply carries no number (russh/ /// explicit port the wire's success reply carries no number (russh/
/// sshd parity — the reply's data only exists for mints) and the /// sshd parity; the reply's data only exists for mints) and the
/// bound port *is* the requested one, which the row surfaces. /// bound port *is* the requested one, which the row surfaces.
/// ///
/// Gates, both **before any wire op**: the `forwarded-tcpip` /// Gates, both **before any wire op**: the `forwarded-tcpip`
/// acceptor must be registered /// acceptor must be registered
/// ([`OpError::NoAcceptor`](crate::OpError) — a listen whose data /// ([`OpError::NoAcceptor`](crate::OpError), a listen whose data
/// leg cannot serve is a half-capability) and the connection's /// leg cannot serve is a half-capability) and the connection's
/// listener count must have room /// listener count must have room
/// ([`OpError::ListenCapExceeded`](crate::OpError) — the /// ([`OpError::ListenCapExceeded`](crate::OpError), the
/// dedicated counter sharing /// dedicated counter sharing
/// [`Limits::max_concurrent_channels`](crate::Limits); see the /// [`Limits::max_concurrent_channels`](crate::Limits); see the
/// module docs' cap relation). The peer's refusal is /// module docs' cap relation). The peer's refusal is
/// [`OpError::ListenRefused`](crate::OpError) (the wire's /// [`OpError::ListenRefused`](crate::OpError) (the wire's
/// `RequestDenied`) and releases the count; the connection stays /// `RequestDenied`) and releases the count; the connection stays
/// usable after every refusal class. The listener is explicitly /// usable after every refusal class. The listener is explicitly
/// cancellable — the returned [`ReverseListener`] handle's /// cancellable, via the returned [`ReverseListener`] handle's
/// `cancel()` (the wire-honest teardown; a cancel is never /// `cancel()` (the wire-honest teardown; a cancel is never
/// triggered by a drop — the module docs' recorded decision). /// triggered by a drop; the module docs' recorded decision).
pub async fn reverse_listen( pub async fn reverse_listen(
&self, &self,
address: impl Into<String>, address: impl Into<String>,
@@ -608,10 +608,10 @@ impl SshConnection {
/// The reverse-listen cancel row (`-R` teardown): the explicit /// The reverse-listen cancel row (`-R` teardown): the explicit
/// cancellation of one bound listener by the raw wire strings /// cancellation of one bound listener by the raw wire strings
/// (sshd parity) — the **stateless** wire form beside the /// (sshd parity), the **stateless** wire form beside the
/// [`ReverseListener`] handles' `cancel()` (compose into the same /// [`ReverseListener`] handles' `cancel()` (compose into the same
/// shared path; a confirmed cancel releases the listener count — /// shared path; a confirmed cancel releases the listener count
/// never twice, the wire's unknown-listener refusal governs the /// (never twice; the wire's unknown-listener refusal governs the
/// repeats). The peer's refusal (unknown listener) is /// repeats). The peer's refusal (unknown listener) is
/// [`OpError::ListenRefused`](crate::OpError). /// [`OpError::ListenRefused`](crate::OpError).
pub async fn reverse_listen_cancel( pub async fn reverse_listen_cancel(
@@ -630,7 +630,7 @@ impl SshConnection {
/// [`SshConnection::reverse_listen`] for the /// [`SshConnection::reverse_listen`] for the
/// `forwarded-streamlocal` slot, plus the **runtime-Unix gate** /// `forwarded-streamlocal` slot, plus the **runtime-Unix gate**
/// (OQ-11: off-Unix, [`OpError::UnixUnavailable`](crate::OpError) /// (OQ-11: off-Unix, [`OpError::UnixUnavailable`](crate::OpError)
/// before any wire op — the bind is the platform-bound operation; /// before any wire op; the bind is the platform-bound operation,
/// the channel ingestion is platform-neutral). /// the channel ingestion is platform-neutral).
#[cfg_attr(not(unix), allow(unused_variables))] #[cfg_attr(not(unix), allow(unused_variables))]
pub async fn reverse_listen_unix( pub async fn reverse_listen_unix(
@@ -667,7 +667,7 @@ impl SshConnection {
} }
/// The streamlocal cancel row (matches by the socket path, sshd /// The streamlocal cancel row (matches by the socket path, sshd
/// parity — see [`SshConnection::reverse_listen_cancel`]; composes /// parity, see [`SshConnection::reverse_listen_cancel`]; composes
/// into the same shared path as the unix handles' `cancel()`). /// into the same shared path as the unix handles' `cancel()`).
#[cfg_attr(not(unix), allow(unused_variables))] #[cfg_attr(not(unix), allow(unused_variables))]
pub async fn reverse_listen_unix_cancel( pub async fn reverse_listen_unix_cancel(
@@ -708,7 +708,7 @@ mod tests {
async fn accept(&self, _open: ForwardedStreamlocal) {} async fn accept(&self, _open: ForwardedStreamlocal) {}
} }
/// - *Contract stamp:* the recorded refuse-never-replace posture — /// - *Contract stamp:* the recorded refuse-never-replace posture:
/// a held slot declines the second install (never a quiet /// a held slot declines the second install (never a quiet
/// displacement of a live acceptor); uninstall frees it. /// displacement of a live acceptor); uninstall frees it.
#[test] #[test]
@@ -736,7 +736,7 @@ mod tests {
} }
/// - *Contract stamp:* the two slots are independent (the /// - *Contract stamp:* the two slots are independent (the
/// per-type-registry decision) — the empty streamlocal slot /// per-type-registry decision); the empty streamlocal slot
/// stays unfeatured while the tcpip slot serves. /// stays unfeatured while the tcpip slot serves.
#[test] #[test]
fn the_slots_are_independent() { fn the_slots_are_independent() {
@@ -789,7 +789,7 @@ mod tests {
} }
/// - *Contract stamp:* the handoff counter's panic-safe release /// - *Contract stamp:* the handoff counter's panic-safe release
/// (the bounded-bookkeeping cap relation) — the /// (the bounded-bookkeeping cap relation); the
/// [`HandoffCapGuard`] releases on unwind, so a panicking /// [`HandoffCapGuard`] releases on unwind, so a panicking
/// assembly acceptor can never strand a held slot past the /// assembly acceptor can never strand a held slot past the
/// handoff task. /// handoff task.
+29 -29
View File
@@ -1,4 +1,4 @@
//! The per-connection **client** handler — the dial surface's russh //! The per-connection **client** handler, the dial surface's russh
//! `client::Handler` impl (`w4-dial`), the mirror of //! `client::Handler` impl (`w4-dial`), the mirror of
//! [`crate::handler`]'s server-direction handler. //! [`crate::handler`]'s server-direction handler.
//! //!
@@ -9,46 +9,46 @@
//! the crate's single fingerprint path ([`crate::fingerprint`]), //! the crate's single fingerprint path ([`crate::fingerprint`]),
//! labels it with the dial target (host + port, parsed from the //! labels it with the dial target (host + port, parsed from the
//! dial address), and hands the whole [`PresentedHostKey`] to the //! dial address), and hands the whole [`PresentedHostKey`] to the
//! injected [`HostKeyPolicy`] — the verdict rides straight through //! injected [`HostKeyPolicy`], and the verdict rides straight
//! (`Ok(policy.decide(..).await)`). The crate is policy-blind; the //! through (`Ok(policy.decide(..).await)`). The crate is policy-blind; the
//! built-in [`RejectAll`](crate::RejectAll) verdict makes every dial //! built-in [`RejectAll`](crate::RejectAll) verdict makes every dial
//! refuse; a `false` verdict is the decline disposition russh //! refuse; a `false` verdict is the decline disposition russh
//! surfaces as its `UnknownKey` wire class //! surfaces as its `UnknownKey` wire class
//! ([`DialError::HostKeyRejected`](crate::DialError) //! ([`DialError::HostKeyRejected`](crate::DialError)),
//! — never an accept, never a retry). //! never an accept, never a retry).
//! 2. **Server-initiated channel opens declined cleanly** (ADR-003 §4, //! 2. **Server-initiated channel opens declined cleanly** (ADR-003 §4,
//! the client-side symmetry rule): russh's default posture for every //! the client-side symmetry rule): russh's default posture for every
//! `server_channel_open_*` hook is accept-and-drop — a //! `server_channel_open_*` hook is accept-and-drop, a
//! half-capability the family forbids. Every hook is overridden //! half-capability the family forbids. Every hook is overridden
//! explicit-over-inherited. The `forwarded-tcpip` / //! explicit-over-inherited. The `forwarded-tcpip` /
//! `forwarded-streamlocal` hooks (the `-R` data leg's types) route //! `forwarded-streamlocal` hooks (the `-R` data leg's types) route
//! through the **acceptor registry** ([`crate::client_accept`] — //! through the **acceptor registry** ([`crate::client_accept`],
//! the `w4-reverse-listen` surface): served only under a registered //! the `w4-reverse-listen` surface): served only under a registered
//! acceptor for that type and under the `tunnel` feature (the //! acceptor for that type and under the `tunnel` feature (the
//! [`with_tunnel_surface`]'s tunnel surface //! [`with_tunnel_surface`]'s tunnel surface
//! wiring; a build without `tunnel` has no registry — every hook //! wiring; a build without `tunnel` has no registry, so every hook
//! declines); unregistered/unfeatured opens answer //! declines); unregistered/unfeatured opens answer
//! [`ChannelOpenFailure::AdministrativelyProhibited`] — a clean //! [`ChannelOpenFailure::AdministrativelyProhibited`], a clean
//! wire-visible refusal the peer observes as an //! wire-visible refusal the peer observes as an
//! unconfirmed open, never an accepted-then-dropped channel. The //! unconfirmed open, never an accepted-then-dropped channel. The
//! accepted open's handoff spawns (the spawn-and-return rule's //! accepted open's handoff spawns (the spawn-and-return rule's
//! client mirror): the hook accepts and returns fast, the duplex + //! client mirror): the hook accepts and returns fast, the duplex +
//! the open's fields ride the payload to the assembly's acceptor //! the open's fields ride the payload to the assembly's acceptor
//! call on its own task. Every other type has no registry slot — //! call on its own task. Every other type has no registry slot:
//! the unknown-type gate //! the unknown-type gate
//! ([`Handler::should_accept_unknown_server_channel`]) answers //! ([`Handler::should_accept_unknown_server_channel`]) answers
//! `false` (the hook's reject-before-open form; its `_unknown` //! `false` (the hook's reject-before-open form; its `_unknown`
//! sibling stays a defensive reject — wire-reachable only if that //! sibling stays a defensive reject, wire-reachable only if that
//! gate ever changed, also fail-closed), and the never-feature rows //! gate ever changed, also fail-closed), and the never-feature rows
//! (x11, agent) decline at any configuration. //! (x11, agent) decline at any configuration.
//! //!
//! The accepted-open handoff carries a **bounded bookkeeping** //! The accepted-open handoff carries a **bounded bookkeeping**
//! counter (`AGENTS.md` convention 9, the cap relation recorded in //! counter (`AGENTS.md` convention 9, the cap relation recorded in
//! [`crate::client_accept`]): a dedicated per-connection counter — //! [`crate::client_accept`]): a dedicated per-connection counter
//! sharing the dial's `Limits::max_concurrent_channels` ceiling the //! (sharing the dial's `Limits::max_concurrent_channels` ceiling the
//! server direction's per-session cap uses — admitted in the hook //! server direction's per-session cap uses), admitted in the hook
//! before the accept, released when the handoff task ends (the //! before the accept, released when the handoff task ends (the
//! acceptor's call completing *or the task unwinding* — //! acceptor's call completing *or the task unwinding*;
//! [`crate::client_accept`]'s `HandoffCapGuard` panic-safety); a full //! [`crate::client_accept`]'s `HandoffCapGuard` panic-safety); a full
//! counter declines the arrival cleanly //! counter declines the arrival cleanly
//! (`cap-exceeded`). //! (`cap-exceeded`).
@@ -79,7 +79,7 @@ use crate::{DialError, fingerprint};
/// the two forwarded hooks route through, plus the accepted-open /// the two forwarded hooks route through, plus the accepted-open
/// handoff counter and its ceiling (the dial's /// handoff counter and its ceiling (the dial's
/// `Limits::max_concurrent_channels`; released when the handoff task /// `Limits::max_concurrent_channels`; released when the handoff task
/// ends — completion or unwind, `HandoffCapGuard`'s panic-safety). /// ends, completion or unwind, `HandoffCapGuard`'s panic-safety).
#[cfg(feature = "tunnel")] #[cfg(feature = "tunnel")]
pub(crate) struct TunnelSurface { pub(crate) struct TunnelSurface {
acceptors: Arc<AcceptorRegistry>, acceptors: Arc<AcceptorRegistry>,
@@ -87,7 +87,7 @@ pub(crate) struct TunnelSurface {
open_cap: std::sync::Arc<ChannelCap>, open_cap: std::sync::Arc<ChannelCap>,
} }
/// The per-connection client handler the dial surface constructs — /// The per-connection client handler the dial surface constructs;
/// see the module docs. Test surface /// see the module docs. Test surface
/// (`#[doc(hidden)]` re-export from the crate root): the contract /// (`#[doc(hidden)]` re-export from the crate root): the contract
/// tests drive the bare hooks, exactly the /// tests drive the bare hooks, exactly the
@@ -96,7 +96,7 @@ pub struct ClientHandler {
policy: std::sync::Arc<dyn HostKeyPolicy>, policy: std::sync::Arc<dyn HostKeyPolicy>,
host: String, host: String,
port: u16, port: u16,
/// The `-R` surface under `tunnel` (`Some` — the dial wires it; /// The `-R` surface under `tunnel` (`Some`, the dial wires it;
/// the bare-hook tests construct without it to pin the decline-all /// the bare-hook tests construct without it to pin the decline-all
/// default posture in the featured builds too). /// default posture in the featured builds too).
#[cfg(feature = "tunnel")] #[cfg(feature = "tunnel")]
@@ -143,10 +143,10 @@ impl russh::client::Handler for ClientHandler {
/// The host-key policy's hook (OQ-05): the presented key or /// The host-key policy's hook (OQ-05): the presented key or
/// certificate's fingerprint is computed on the crate's single /// certificate's fingerprint is computed on the crate's single
/// fingerprint path (certificates fingerprint their inner key — /// fingerprint path (certificates fingerprint their inner key;
/// the trust decision is the policy's either way) and the whole /// the trust decision is the policy's either way) and the whole
/// [`PresentedHostKey`] rides the policy; the verdict is passed /// [`PresentedHostKey`] rides the policy; the verdict is passed
/// through untouched. `Ok(false)` — russh's decline — is the /// through untouched. `Ok(false)`, russh's decline, is the
/// disposition, never an error path; the policy's own record /// disposition, never an error path; the policy's own record
/// carries why. /// carries why.
async fn check_server_key( async fn check_server_key(
@@ -186,7 +186,7 @@ impl russh::client::Handler for ClientHandler {
} }
/// The `forwarded-tcpip` hook (the `-R` data leg's gate): the /// The `forwarded-tcpip` hook (the `-R` data leg's gate): the
/// registry's slot read at arrival time — registered + room → /// registry's slot read at arrival time (registered + room →
/// accept and hand the assembly the open on a spawned task; no /// accept and hand the assembly the open on a spawned task; no
/// acceptor / no room → the clean decline, never accept-and-drop. /// acceptor / no room → the clean decline, never accept-and-drop.
async fn server_channel_open_forwarded_tcpip( async fn server_channel_open_forwarded_tcpip(
@@ -247,10 +247,10 @@ impl russh::client::Handler for ClientHandler {
} }
} }
/// The `forwarded-streamlocal` hook — the unix-slot's mirror of /// The `forwarded-streamlocal` hook, the unix-slot's mirror of
/// the tcpip gate (the ingestion is platform-neutral: no runtime /// the tcpip gate (the ingestion is platform-neutral: no runtime
/// gate here; the listen rows carry the OQ-11 gate on the bind — /// gate here; the listen rows carry the OQ-11 gate on the bind,
/// the recorded asymmetry, see [`crate::client_accept`]). /// the recorded asymmetry (see [`crate::client_accept`]).
async fn server_channel_open_forwarded_streamlocal( async fn server_channel_open_forwarded_streamlocal(
&mut self, &mut self,
channel: Channel<Msg>, channel: Channel<Msg>,
@@ -350,8 +350,8 @@ impl russh::client::Handler for ClientHandler {
Ok(()) Ok(())
} }
/// A never-feature row (ADR-003 §5): x11 has no family seam — the /// A never-feature row (ADR-003 §5): x11 has no family seam, so
/// decline is not even registry territory. /// the decline is not even registry territory.
async fn server_channel_open_x11( async fn server_channel_open_x11(
&mut self, &mut self,
channel: Channel<Msg>, channel: Channel<Msg>,
@@ -368,7 +368,7 @@ impl russh::client::Handler for ClientHandler {
} }
/// A never-feature row (ADR-003 §5): agent forwarding is the /// A never-feature row (ADR-003 §5): agent forwarding is the
/// anti-pattern of the vault custody model — always declined. /// anti-pattern of the vault custody model, always declined.
async fn server_channel_open_agent_forward( async fn server_channel_open_agent_forward(
&mut self, &mut self,
channel: Channel<Msg>, channel: Channel<Msg>,
@@ -384,7 +384,7 @@ impl russh::client::Handler for ClientHandler {
/// Wire-reachable only if /// Wire-reachable only if
/// [`russh::client::Handler::should_accept_unknown_server_channel`] /// [`russh::client::Handler::should_accept_unknown_server_channel`]
/// ever answers `true` (it does not — the skeleton's default is /// ever answers `true` (it does not; the skeleton's default is
/// fail-closed); the defensive reject keeps the posture honest /// fail-closed); the defensive reject keeps the posture honest
/// even then. /// even then.
async fn server_channel_open_unknown( async fn server_channel_open_unknown(
@@ -415,7 +415,7 @@ mod tests {
/// - *Contract stamp:* ADR-004 decision 6 posture mirror (the /// - *Contract stamp:* ADR-004 decision 6 posture mirror (the
/// client-side: the policy is injected, the handler never /// client-side: the policy is injected, the handler never
/// decides trust), pinned at the dial wiring — here, that the /// decides trust), pinned at the dial wiring; here, that the
/// labels ride the construction so the policy sees /// labels ride the construction so the policy sees
/// `PresentedHostKey { host, port }` verbatim, and the /// `PresentedHostKey { host, port }` verbatim, and the
/// fingerprint rides the crate's single fingerprint path. /// fingerprint rides the crate's single fingerprint path.
+66 -66
View File
@@ -1,67 +1,67 @@
//! The client op rows (`w4-op-rows`) — `client.md` §op rows in code: //! The client op rows (`w4-op-rows`), `client.md` §op rows in code:
//! the family op → vanilla translation the reverse-runner shape //! the family op → vanilla translation the reverse-runner shape
//! drives (the hub dialed [`SshConnection`](crate::SshConnection) and //! drives (the hub dialed [`SshConnection`](crate::SshConnection) and
//! now opens every channel itself). **Type symmetry at the seam is //! now opens every channel itself). **Type symmetry at the seam is
//! the contract:** family-protocol types in, vanilla wire ops out, no //! the contract:** family-protocol types in, vanilla wire ops out, no
//! vanilla-concept leaks up — the rows' inputs are the mapped //! vanilla-concept leaks up; the rows' inputs are the mapped
//! sibling crates' types (the alktty session shapes for the exec/pty //! sibling crates' types (the alktty session shapes for the exec/pty
//! rows, alkgit's `Service` for the git row) and the surfaces are //! rows, alkgit's `Service` for the git row) and the surfaces are
//! crate-owned (`RemoteSession` / `TunnelChannel` / `GitPackSession`), //! crate-owned (`RemoteSession` / `TunnelChannel` / `GitPackSession`),
//! the failure taxonomy [`OpError`](crate::OpError). All rows share //! the failure taxonomy [`OpError`](crate::OpError). All rows share
//! the russh 0.65 mechanics POC-3 §D.3 recorded: //! the russh 0.65 mechanics POC-3 §D.3 recorded:
//! //!
//! - `Channel::exec()` — there is **no `request_exec` at this pin**. //! - `Channel::exec()`: there is **no `request_exec` at this pin**.
//! - A request's Success/Failure replies arrive **on the channel //! - A request's Success/Failure replies arrive **on the channel
//! queue via `wait()`**, not as `Err` from the request call — every //! queue via `wait()`**, not as `Err` from the request call, so every
//! request-carrying row drains until the expected message, //! request-carrying row drains until the expected message,
//! `continue`-ing past the interleaved rest, and maps a `Failure` //! `continue`-ing past the interleaved rest, and maps a `Failure`
//! to [`OpError::RequestRefused`](crate::OpError). The drain is //! to [`OpError::RequestRefused`](crate::OpError). The drain is
//! message-capped (bounded resources: a peer that never answers //! message-capped (bounded resources: a peer that never answers
//! cannot loop it unboundedly; the *connection's* wall clock — the //! cannot loop it unboundedly; the *connection's* wall clock, the
//! dial's always-set [`Limits::inactivity_timeout`](crate::Limits) — //! dial's always-set [`Limits::inactivity_timeout`](crate::Limits),
//! bounds the idle waits). //! bounds the idle waits).
//! - **The typed opens resolve `Err` directly on a server reject** //! - **The typed opens resolve `Err` directly on a server reject**
//! (POC-1 §6) — [`OpError::OpenDeclined`](crate::OpError), family-mirrored //! (POC-1 §6) to [`OpError::OpenDeclined`](crate::OpError), family-mirrored
//! [`OpenDeclineReason`](crate::OpenDeclineReason), **and the //! [`OpenDeclineReason`](crate::OpenDeclineReason), **and the
//! connection stays usable after the decline** (row-pinned per //! connection stays usable after the decline** (row-pinned per
//! decline class). //! decline class).
//! - **No channel-queue consumption where a `into_stream()` conversion //! - **No channel-queue consumption where a `into_stream()` conversion
//! follows** (POC-1 §6's fresh-`wait()` caveat) — the tunnel rows //! follows** (POC-1 §6's fresh-`wait()` caveat): the tunnel rows
//! (the only `into_stream` consumers) go typed-open → straight //! (the only `into_stream` consumers) go typed-open → straight
//! conversion, nothing between. The exit-**consuming** rows //! conversion, nothing between. The exit-**consuming** rows
//! (exec/pty, git) cannot ride `into_stream` at all — the //! (exec/pty, git) cannot ride `into_stream` at all, since the
//! conversion's reader side swallows every non-`Data` message, //! conversion's reader side swallows every non-`Data` message,
//! which is exactly where `ExitStatus`/`ExitSignal` live — so they //! which is exactly where `ExitStatus`/`ExitSignal` live, so they
//! run the **split-halves wait-pump** (`Channel::split()` + a //! run the **split-halves wait-pump** (`Channel::split()` + a
//! spawned task draining `wait()`), the shape the server //! spawned task draining `wait()`), the shape the server
//! direction's git row landed for the same reason //! direction's git row landed for the same reason
//! (`w4-git-exit-close`). The exit-ride is the family plane's (§the //! (`w4-git-exit-close`). The exit-ride is the family plane's (§the
//! session rows) or the consumed-report oneshot's (§the git row). //! session rows) or the consumed-report oneshot's (§the git row).
//! - `window_change`/`signal` are fire-and-forget on the wire (no SSH //! - `window_change`/`signal` are fire-and-forget on the wire (no SSH
//! reply exists) — the pumps send and drop the outcome, never //! reply exists); the pumps send and drop the outcome, never
//! awaiting one. //! awaiting one.
//! //!
//! ## The family plane (the session rows) //! ## The family plane (the session rows)
//! //!
//! The exec/pty session's data plane is the **alktty chunk framing in //! The exec/pty session's data plane is the **alktty chunk framing in
//! both directions** — the exact plane the server direction's session //! both directions**, the exact plane the server direction's session
//! bridge speaks ([`crate::tty_map`]), consumed not re-derived: //! bridge speaks ([`crate::tty_map`]), consumed not re-derived:
//! stdin chunks (`STREAM_STDIN`) ride the wire `data` (the //! stdin chunks (`STREAM_STDIN`) ride the wire `data` (the
//! zero-length sentinel = wire EOF, the bridge's decided translation — //! zero-length sentinel = wire EOF, the bridge's decided translation,
//! the session keeps pumping after it); `CTRL_IN` chunks translate to //! the session keeps pumping after it); `CTRL_IN` chunks translate to
//! the wire request rows (`Resize` → `window_change` (the session //! the wire request rows (`Resize` → `window_change` (the session
//! dimension clamp is not needed — the family plane is already //! dimension clamp is not needed; the family plane is already
//! `u16`), `Signal{name}` → `signal` through the shared table's //! `u16`), `Signal{name}` → `signal` through the shared table's
//! name→`Sig` map, `Eof` → wire EOF); wire `data` → stdout chunks, //! name→`Sig` map, `Eof` → wire EOF); wire `data` → stdout chunks,
//! wire `extended_data(1)` → stderr chunks; and the exit rides //! wire `extended_data(1)` → stderr chunks; and the exit rides
//! **in-band as the family's exit chunk** (`STREAM_CTRL_OUT`, //! **in-band as the family's exit chunk** (`STREAM_CTRL_OUT`,
//! exit-chunk-is-last — alktty's ADR-004, mapping.md's contract): //! exit-chunk-is-last, alktty's ADR-004, mapping.md's contract):
//! wire `ExitStatus(N)` → code `N`, wire `ExitSignal(name)` → //! wire `ExitStatus(N)` → code `N`, wire `ExitSignal(name)` →
//! `−signum` through the shared table's inverse read //! `−signum` through the shared table's inverse read
//! ([`crate::exit_code`] — the derivation contract's client arm; a //! ([`crate::exit_code`], the derivation contract's client arm; a
//! name with no table entry rides the `−1` sentinel, the mapping //! name with no table entry rides the `−1` sentinel, the mapping
//! never invents). A session that ends with **no exit report** //! never invents). A session that ends with **no exit report**
//! delivers no exit chunk — the output half just ends (mapping.md's //! delivers no exit chunk; the output half just ends (mapping.md's
//! no-exit-chunk close family: "reports nothing"). The session's //! no-exit-chunk close family: "reports nothing"). The session's
//! ttymodes never ride the wire pty request (the OQ-44 //! ttymodes never ride the wire pty request (the OQ-44
//! reserved-JSON posture mirrored: the door never invents wire //! reserved-JSON posture mirrored: the door never invents wire
@@ -78,30 +78,30 @@
//! | tunnel target (`-L`; the alksocks `DialFn`) | `tunnel` | `channel_open_direct_tcpip` | [`SshConnection::tunnel_direct`] | //! | tunnel target (`-L`; the alksocks `DialFn`) | `tunnel` | `channel_open_direct_tcpip` | [`SshConnection::tunnel_direct`] |
//! | the streamlocal twin | `tunnel` | `channel_open_direct_streamlocal`, runtime-Unix-gated | [`SshConnection::tunnel_direct_unix`] | //! | the streamlocal twin | `tunnel` | `channel_open_direct_streamlocal`, runtime-Unix-gated | [`SshConnection::tunnel_direct_unix`] |
//! | upstream git service | `git` | the shared grammar's command + `exec`; the pack duplex | [`SshConnection::git_service`] | //! | upstream git service | `git` | the shared grammar's command + `exec`; the pack duplex | [`SshConnection::git_service`] |
//! | sftp | *every build* | **none** — the OQ-13 decline placeholder | [`SshConnection::sftp`] | //! | sftp | *every build* | **none**, the OQ-13 decline placeholder | [`SshConnection::sftp`] |
//! //!
//! The exec-command **length budget** applies identically to the //! The exec-command **length budget** applies identically to the
//! command-carrying rows (the server direction's pre-parse cap //! command-carrying rows (the server direction's pre-parse cap
//! mirrored at the command-construction gate — bounded resources; //! mirrored at the command-construction gate; bounded resources,
//! an unbounded command buffer can never ride a dial): //! an unbounded command buffer can never ride a dial):
//! [`OpError::CommandTooLong`](crate::OpError) before any wire op. //! [`OpError::CommandTooLong`](crate::OpError) before any wire op.
//! //!
//! No client-side in-crate channel cap *admits* ops (the server //! No client-side in-crate channel cap *admits* ops (the server
//! direction's cap is a wire-battery defense; the client rows are //! direction's cap is a wire-battery defense; the client rows are
//! hub-driven — the family app chose the dial): bounded resources on //! hub-driven (the family app chose the dial): bounded resources on
//! this direction are the config's always-on wall clock/keepalives, //! this direction are the config's always-on wall clock/keepalives,
//! the russh windows, the bounded duplexes/mpscs the pumps ride, and //! the russh windows, the bounded duplexes/mpscs the pumps ride, and
//! the drain cap — every spawn bounded by its own task's end //! the drain cap; every spawn bounded by its own task's end
//! conditions. //! conditions.
//! //!
//! ## Spawn posture //! ## Spawn posture
//! //!
//! The op methods await their launch (typed open, requests, the //! The op methods await their launch (typed open, requests, the
//! reply drains) on the *caller's* task — never the russh packet //! reply drains) on the *caller's* task, never the russh packet
//! loop's — so the server direction's spawn-and-return deadlock class //! loop's, so the server direction's spawn-and-return deadlock class
//! does not exist here; the long-lived pumps spawn at launch and end //! does not exist here; the long-lived pumps spawn at launch and end
//! on their terminal conditions (the exit chunk, the channel's end, //! on their terminal conditions (the exit chunk, the channel's end,
//! or the hub dropping the surface — a drop is wire-clean: the input //! or the hub dropping the surface; a drop is wire-clean: the input
//! pump's terminal path closes the channel, the pump pair reaping as //! pump's terminal path closes the channel, the pump pair reaping as
//! their duplex halves drop). //! their duplex halves drop).
@@ -117,13 +117,13 @@ use crate::client::SshConnection;
use crate::client_handler::ClientHandler; use crate::client_handler::ClientHandler;
use crate::error::OpError; use crate::error::OpError;
/// The op-row pump duplex capacity — the crate's bridge-pair /// The op-row pump duplex capacity, the crate's bridge-pair
/// convention (the wave-2 loopback harness's per-direction capacity), /// convention (the wave-2 loopback harness's per-direction capacity),
/// keeping every pump's backpressure bounded. /// keeping every pump's backpressure bounded.
#[cfg(any(feature = "session", feature = "git"))] #[cfg(any(feature = "session", feature = "git"))]
const OP_DUPLEX_CAPACITY: usize = 65_536; const OP_DUPLEX_CAPACITY: usize = 65_536;
/// The pump-side read buffer — the SSH-side granularity (per-chunk /// The pump-side read buffer, the SSH-side granularity (per-chunk
/// payloads are bounded by the dial's `maximum_packet_size`). /// payloads are bounded by the dial's `maximum_packet_size`).
#[cfg(feature = "git")] #[cfg(feature = "git")]
const PUMP_BUF: usize = 16 * 1024; const PUMP_BUF: usize = 16 * 1024;
@@ -137,14 +137,14 @@ const DRAIN_MESSAGE_CAP: usize = 1024;
/// The originator label the door puts on the wire's `direct-tcpip` /// The originator label the door puts on the wire's `direct-tcpip`
/// opens (the SSH originator fields are what a vanilla client fills /// opens (the SSH originator fields are what a vanilla client fills
/// in for its own local listener; this door's hub-driven dials carry /// in for its own local listener; this door's hub-driven dials carry
/// the door's label and no port — recorded decision: nothing /// the door's label and no port, recorded decision: nothing
/// invented, nothing leaked, the peer's logs name the door). /// invented, nothing leaked, the peer's logs name the door).
#[cfg(feature = "tunnel")] #[cfg(feature = "tunnel")]
const TUNNEL_ORIGINATOR: &str = "alk-ssh"; const TUNNEL_ORIGINATOR: &str = "alk-ssh";
/// The wire exit status → the family `i32` exit-code convention: a /// The wire exit status → the family `i32` exit-code convention: a
/// code beyond the family `i32` range is unwritable as an exit-chunk /// code beyond the family `i32` range is unwritable as an exit-chunk
/// code; the clamp rides (defensive corner — vanilla command codes /// code; the clamp rides (defensive corner, vanilla command codes
/// are far below it; the mapping never invents a value, it narrows). /// are far below it; the mapping never invents a value, it narrows).
#[cfg(any(feature = "session", feature = "git"))] #[cfg(any(feature = "session", feature = "git"))]
fn family_exit_code(status: u32) -> i32 { fn family_exit_code(status: u32) -> i32 {
@@ -155,7 +155,7 @@ fn family_exit_code(status: u32) -> i32 {
/// op taxonomy (a channel-open failure → /// op taxonomy (a channel-open failure →
/// [`OpError::OpenDeclined`](crate::OpError::OpenDeclined), /// [`OpError::OpenDeclined`](crate::OpError::OpenDeclined),
/// family-mirrored; everything else transparent). The russh level's /// family-mirrored; everything else transparent). The russh level's
/// test observable (`POC-1 §6`'s `Err(ChannelOpenFailure)` — the /// test observable (`POC-1 §6`'s `Err(ChannelOpenFailure)`, the
/// awaiting typed call's resolution) rides this point; the /// awaiting typed call's resolution) rides this point; the
/// connection stays usable after every decline (row-pinned). /// connection stays usable after every decline (row-pinned).
#[cfg(any(feature = "session", feature = "git", feature = "tunnel"))] #[cfg(any(feature = "session", feature = "git", feature = "tunnel"))]
@@ -165,7 +165,7 @@ fn open_error(error: russh::Error) -> OpError {
/// The request-reply drain (the §module docs' recorded mechanics, /// The request-reply drain (the §module docs' recorded mechanics,
/// shared by the exec/pty rows and the git row): Success/Failure via /// shared by the exec/pty rows and the git row): Success/Failure via
/// `wait()` — never an `Err` from the request call — `continue` past /// `wait()`, never an `Err` from the request call; `continue` past
/// the interleaved rest (the POC-3 §D.3 break-on-first-message trap, /// the interleaved rest (the POC-3 §D.3 break-on-first-message trap,
/// not repeated), message-capped. `Failure` is the wire /// not repeated), message-capped. `Failure` is the wire
/// request-refusal class ([`OpError::RequestRefused`]); the /// request-refusal class ([`OpError::RequestRefused`]); the
@@ -197,28 +197,28 @@ async fn drain_launch_request(channel: &mut russh::Channel<Msg>) -> Result<(), O
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// The sftp placeholder — every build (OQ-13; no feature, no dep) // The sftp placeholder, every build (OQ-13; no feature, no dep)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
impl SshConnection { impl SshConnection {
/// The sftp op — the client-side half of the OQ-13 decline /// The sftp op, the client-side half of the OQ-13 decline
/// placeholder: **declines in every build, before any wire op** /// placeholder: **declines in every build, before any wire op**
/// (never a half-opened subsystem channel). The feature exists /// (never a half-opened subsystem channel). The feature exists
/// and ships in the build string; the route does not, and arrives /// and ships in the build string; the route does not, and arrives
/// with alkfs as its own ADR — both directions' placeholders are /// with alkfs as its own ADR; both directions' placeholders are
/// then replaced by real rows (`client.md` §What the crate does /// then replaced by real rows (`client.md` §What the crate does
/// *not* include). Compiles feature-free; the `Ok` type is /// *not* include). Compiles feature-free; the `Ok` type is
/// `Infallible` by design — nothing can ever populate it here. /// `Infallible` by design; nothing can ever populate it here.
pub async fn sftp(&self) -> Result<std::convert::Infallible, OpError> { pub async fn sftp(&self) -> Result<std::convert::Infallible, OpError> {
Err(OpError::SftpNotServed) Err(OpError::SftpNotServed)
} }
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// The session rows — exec / pty (under `session`) // The session rows: exec / pty (under `session`)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// A remote **session** — the family session plane over the runner /// A remote **session**: the family session plane over the runner
/// (the session rows' surface): the alktty chunk framing in both /// (the session rows' surface): the alktty chunk framing in both
/// directions, exactly the plane the server direction's session /// directions, exactly the plane the server direction's session
/// bridge speaks (`tty_map`), reversed. Two duplex halves /// bridge speaks (`tty_map`), reversed. Two duplex halves
@@ -245,7 +245,7 @@ impl std::fmt::Debug for RemoteSession {
} }
} }
/// Which request launches a session — the family op's payload /// Which request launches a session: the family op's payload
/// (§the row inventory; pipe-mode exec vs the shell form). /// (§the row inventory; pipe-mode exec vs the shell form).
#[cfg(feature = "session")] #[cfg(feature = "session")]
enum LaunchRequest { enum LaunchRequest {
@@ -255,11 +255,11 @@ enum LaunchRequest {
#[cfg(feature = "session")] #[cfg(feature = "session")]
impl SshConnection { impl SshConnection {
/// The pipe-mode exec row: `ssh host "cmd"` in, family-typed — /// The pipe-mode exec row: `ssh host "cmd"` in, family-typed;
/// the session channel's open + the wire `exec` request, then the /// the session channel's open + the wire `exec` request, then the
/// chunk-framed session (§the family plane; stderr separated). /// chunk-framed session (§the family plane; stderr separated).
/// The command rides the wire verbatim (the remote end's shell /// The command rides the wire verbatim (the remote end's shell
/// parses it — the vanilla exec grammar; never argv-split). /// parses it, the vanilla exec grammar; never argv-split).
pub async fn exec(&self, command: impl Into<String>) -> Result<RemoteSession, OpError> { pub async fn exec(&self, command: impl Into<String>) -> Result<RemoteSession, OpError> {
let command = command.into(); let command = command.into();
if command.len() > self.budgets().max_exec_command_len { if command.len() > self.budgets().max_exec_command_len {
@@ -272,7 +272,7 @@ impl SshConnection {
/// carries the family /// carries the family
/// [`alktty::negotiation::TerminalParamsWire`](alktty::negotiation:: /// [`alktty::negotiation::TerminalParamsWire`](alktty::negotiation::
/// TerminalParamsWire) first (its acceptance drained), then the /// TerminalParamsWire) first (its acceptance drained), then the
/// exec. **A refused pty refuses the whole row** — never a /// exec. **A refused pty refuses the whole row**: never a
/// half-capability, never a silent pty-less fall. /// half-capability, never a silent pty-less fall.
pub async fn pty_exec( pub async fn pty_exec(
&self, &self,
@@ -292,8 +292,8 @@ impl SshConnection {
} }
/// The shell row ("own a shell on the runner"): with /// The shell row ("own a shell on the runner"): with
/// `params` — the pty mode (the interactive `ssh` shape); with /// `params`, this is the pty mode (the interactive `ssh` shape); with
/// `None` — the pipe-mode shell (the `ssh -T` shape; a pty-less /// `None`, the pipe-mode shell (the `ssh -T` shape; a pty-less
/// shell request is legitimate vanilla and served, never declined, /// shell request is legitimate vanilla and served, never declined,
/// mirroring the server direction's decided parity). /// mirroring the server direction's decided parity).
pub async fn shell( pub async fn shell(
@@ -361,7 +361,7 @@ async fn open_remote_session(
/// `CTRL_IN` `Eof` / the hub's input half dropping = the wire EOF /// `CTRL_IN` `Eof` / the hub's input half dropping = the wire EOF
/// *once*, then the idle-for-kill state (the session keeps pumping); /// *once*, then the idle-for-kill state (the session keeps pumping);
/// the terminal kill (from the output pump's terminal paths) → the /// the terminal kill (from the output pump's terminal paths) → the
/// wire `channel_close` — the session's drop is wire-clean. /// wire `channel_close`; the session's drop is wire-clean.
#[cfg(feature = "session")] #[cfg(feature = "session")]
async fn session_input_pump( async fn session_input_pump(
write_half: russh::ChannelWriteHalf<Msg>, write_half: russh::ChannelWriteHalf<Msg>,
@@ -432,12 +432,12 @@ async fn session_input_pump(
} }
} }
/// One frame read from the hub→door plane — the *no-disambiguation* /// One frame read from the hub→door plane, in the *no-disambiguation*
/// read direction: this plane's chunks are the family protocol's own /// read direction: this plane's chunks are the family protocol's own
/// (stdin `0` / `CTRL_IN` `3`); no negotiation-error frames exist /// (stdin `0` / `CTRL_IN` `3`); no negotiation-error frames exist
/// toward the door, so the read is the protocol's direct /// toward the door, so the read is the protocol's direct
/// [`ChunkReader::read_chunk`] (a `0x00`-leading frame here is a /// [`ChunkReader::read_chunk`] (a `0x00`-leading frame here is a
/// zero-length stdin chunk — the sentinel — and reads as exactly /// zero-length stdin chunk (the sentinel) and reads as exactly
/// that). A rogue frame is the bounds-checked [`RawError`], mapped to /// that). A rogue frame is the bounds-checked [`RawError`], mapped to
/// the pump's [`HubFrame::Break`]. /// the pump's [`HubFrame::Break`].
#[cfg(feature = "session")] #[cfg(feature = "session")]
@@ -459,7 +459,7 @@ async fn hub_frame(reader: &mut alktty::wire::ChunkReader<tokio::io::DuplexStrea
/// The session row's **output pump**: the wire → the hub's chunk /// The session row's **output pump**: the wire → the hub's chunk
/// plane (§the family plane). `data` → stdout chunks, /// plane (§the family plane). `data` → stdout chunks,
/// `extended_data(1)` → stderr chunks, and the `ExitStatus` / /// `extended_data(1)` → stderr chunks, and the `ExitStatus` /
/// `ExitSignal` terminal → the in-band exit chunk (last) — then the /// `ExitSignal` terminal → the in-band exit chunk (last), then the
/// kill fires and the input pump closes the wire. A channel end with /// kill fires and the input pump closes the wire. A channel end with
/// no report → no exit chunk; the output half just ends. /// no report → no exit chunk; the output half just ends.
#[cfg(feature = "session")] #[cfg(feature = "session")]
@@ -512,10 +512,10 @@ async fn session_output_pump(
} }
} }
/// The family exit chunk's write — `{"type":"exit","code":N}` as the /// The family exit chunk's write: `{"type":"exit","code":N}` as the
/// in-band terminal chunk (exit-chunk-is-last), serialized by the /// in-band terminal chunk (exit-chunk-is-last), serialized by the
/// family currency's own serializer. An unserializable exit (not a /// family currency's own serializer. An unserializable exit (not a
/// reachable shape) writes nothing — the no-report family, never a /// reachable shape) writes nothing; the no-report family, never a
/// corrupted frame. /// corrupted frame.
#[cfg(feature = "session")] #[cfg(feature = "session")]
async fn write_exit_chunk( async fn write_exit_chunk(
@@ -533,16 +533,16 @@ async fn write_exit_chunk(
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// The tunnel rows — direct-tcpip / direct-streamlocal (under `tunnel`) // The tunnel rows: direct-tcpip / direct-streamlocal (under `tunnel`)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// A tunnel channel — the tunnel rows' surface: a plain byte duplex /// A tunnel channel, the tunnel rows' surface: a plain byte duplex
/// over the open channel (`-L`'s pump; the alksocks `DialFn` composes /// over the open channel (`-L`'s pump; the alksocks `DialFn` composes
/// exactly this type). Nothing vanilla-typed rides the family /// exactly this type). Nothing vanilla-typed rides the family
/// surface — the duplex's `poll_shutdown` carries the wire EOF. /// surface; the duplex's `poll_shutdown` carries the wire EOF.
#[cfg(feature = "tunnel")] #[cfg(feature = "tunnel")]
pub struct TunnelChannel { pub struct TunnelChannel {
/// The russh channel's byte duplex (`pub(crate)` — the `-R` leg's /// The russh channel's byte duplex (`pub(crate)`; the `-R` leg's
/// acceptor payloads construct the type over a server-initiated /// acceptor payloads construct the type over a server-initiated
/// open's `into_stream()` conversion; downstream crates see the /// open's `into_stream()` conversion; downstream crates see the
/// type opaque as ever). /// type opaque as ever).
@@ -595,11 +595,11 @@ impl tokio::io::AsyncWrite for TunnelChannel {
#[cfg(feature = "tunnel")] #[cfg(feature = "tunnel")]
impl SshConnection { impl SshConnection {
/// The tunnel-target row (`-L`; the alksocks `DialFn` composes /// The tunnel-target row (`-L`; the alksocks `DialFn` composes
/// over it): the typed open — server reject → /// over it): the typed open; server reject →
/// [`OpError::OpenDeclined`](crate::OpError) with the mirrored /// [`OpError::OpenDeclined`](crate::OpError) with the mirrored
/// class, and the connection stays usable (row-pinned per /// class, and the connection stays usable (row-pinned per
/// decline class). Between the open and the `into_stream()` /// decline class). Between the open and the `into_stream()`
/// conversion **nothing else happens** — no channel-queue /// conversion **nothing else happens**: no channel-queue
/// consumption (POC-1 §6's caveat, respected by construction; the /// consumption (POC-1 §6's caveat, respected by construction; the
/// data pump is the raw byte duplex). /// data pump is the raw byte duplex).
pub async fn tunnel_direct( pub async fn tunnel_direct(
@@ -618,7 +618,7 @@ impl SshConnection {
} }
/// The streamlocal twin (`direct-streamlocal@openssh.com`): same /// The streamlocal twin (`direct-streamlocal@openssh.com`): same
/// typed-open/pump shape, the OQ-11 runtime-Unix gate mirrored — /// typed-open/pump shape, the OQ-11 runtime-Unix gate mirrored;
/// **compiles wherever `tunnel` compiles**, and off-Unix the op /// **compiles wherever `tunnel` compiles**, and off-Unix the op
/// declines cleanly ([`OpError::UnixUnavailable`](crate::OpError)) /// declines cleanly ([`OpError::UnixUnavailable`](crate::OpError))
/// before any wire op. /// before any wire op.
@@ -646,15 +646,15 @@ impl SshConnection {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// The git row — upstream exec consumption (under `git`) // The git row: upstream exec consumption (under `git`)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// An upstream **git service channel** — the git row's surface: the /// An upstream **git service channel**, the git row's surface: the
/// raw pack duplex (the git protocol's own byte plane rides the /// raw pack duplex (the git protocol's own byte plane rides the
/// channel untouched), the sshd-side stderr split out, and the /// channel untouched), the sshd-side stderr split out, and the
/// consumed exit report at the family's `i32` exit-code convention /// consumed exit report at the family's `i32` exit-code convention
/// (`ExitStatus(N)` → `N`; `ExitSignal` → `−signum` through the /// (`ExitStatus(N)` → `N`; `ExitSignal` → `−signum` through the
/// shared table — the mapping never invents; a receive `Err` on /// shared table, the mapping never invents; a receive `Err` on
/// `exit` = the pump ended with **no exit report at all**, the /// `exit` = the pump ended with **no exit report at all**, the
/// no-exit-chunk close family's mirror). /// no-exit-chunk close family's mirror).
#[cfg(feature = "git")] #[cfg(feature = "git")]
@@ -666,7 +666,7 @@ pub struct GitPackSession {
/// the pack). /// the pack).
pub output: tokio::io::DuplexStream, pub output: tokio::io::DuplexStream,
/// The wire `extended_data(1)` (the remote program's stderr /// The wire `extended_data(1)` (the remote program's stderr
/// diagnostics) — raw bytes; a hub uninterested in diagnostics /// diagnostics), raw bytes; a hub uninterested in diagnostics
/// drops the half (the pump then discards further stderr). /// drops the half (the pump then discards further stderr).
pub stderr: tokio::io::DuplexStream, pub stderr: tokio::io::DuplexStream,
/// The consumed exit report (see the type docs). /// The consumed exit report (see the type docs).
@@ -683,7 +683,7 @@ impl std::fmt::Debug for GitPackSession {
#[cfg(feature = "git")] #[cfg(feature = "git")]
impl SshConnection { impl SshConnection {
/// The upstream git row: the shared exec grammar's command /// The upstream git row: the shared exec grammar's command
/// (`git-{upload,receive}-pack '/<repo>'` — built through /// (`git-{upload,receive}-pack '/<repo>'`, built through
/// `crate::dispatch`'s construction arm, the parse arm's exact /// `crate::dispatch`'s construction arm, the parse arm's exact
/// inverse; no forked grammar), pointed at the vanilla `sshd`: /// inverse; no forked grammar), pointed at the vanilla `sshd`:
/// typed open + `exec`, the pack duplex pumped, the exit report /// typed open + `exec`, the pack duplex pumped, the exit report
@@ -695,9 +695,9 @@ impl SshConnection {
/// [`OpError::InvalidRepoId`](crate::OpError); /// [`OpError::InvalidRepoId`](crate::OpError);
/// the constructed command is checked against the exec-length /// the constructed command is checked against the exec-length
/// budget ([`crate::Limits::max_exec_command_len`]) → /// budget ([`crate::Limits::max_exec_command_len`]) →
/// [`OpError::CommandTooLong`](crate::OpError) — both **before any /// [`OpError::CommandTooLong`](crate::OpError), both **before any
/// wire op**. (The grammar's other decline classes — /// wire op**. (The grammar's other decline classes,
/// `unknown-service` / `service-only` — are structurally /// `unknown-service` / `service-only`, are structurally
/// unreachable here: the [`alkgit::Service`] input type carries /// unreachable here: the [`alkgit::Service`] input type carries
/// only the two serving services; the construction arm can emit /// only the two serving services; the construction arm can emit
/// no other token.) /// no other token.)
@@ -793,7 +793,7 @@ async fn git_input_pump(
/// The git row's **output pump**: the wire → the pack duplex + /// The git row's **output pump**: the wire → the pack duplex +
/// stderr; the exit report consumed here (the family code) and the /// stderr; the exit report consumed here (the family code) and the
/// kill fired — then the input pump closes the wire. A channel end /// kill fired, then the input pump closes the wire. A channel end
/// with no report → no exit fired; the `exit` oneshot drops (the /// with no report → no exit fired; the `exit` oneshot drops (the
/// hub's receive reads the no-report mirror). /// hub's receive reads the no-report mirror).
#[cfg(feature = "git")] #[cfg(feature = "git")]
@@ -848,7 +848,7 @@ mod tests {
use russh::ChannelOpenFailure; use russh::ChannelOpenFailure;
/// - *Contract stamp:* `client.md` §op rows (no vanilla-concept /// - *Contract stamp:* `client.md` §op rows (no vanilla-concept
/// leaks up) + POC-1 §6 — the typed open's decline mapping /// leaks up) + POC-1 §6: the typed open's decline mapping
/// preserves the wire class (`error.rs`'s mirror rows; this /// preserves the wire class (`error.rs`'s mirror rows; this
/// pins the op rows' own consumption point). /// pins the op rows' own consumption point).
#[test] #[test]
@@ -870,7 +870,7 @@ mod tests {
} }
/// - *Contract stamp:* the family exit-currency clamp (§the /// - *Contract stamp:* the family exit-currency clamp (§the
/// family plane) — ordinary codes ride identity, beyond-range /// family plane); ordinary codes ride identity, beyond-range
/// ones narrow (never invent). /// ones narrow (never invent).
#[cfg(any(feature = "session", feature = "git"))] #[cfg(any(feature = "session", feature = "git"))]
#[test] #[test]
+82 -82
View File
@@ -1,20 +1,20 @@
//! The pure configuration surface — the shapes every later wave's //! The pure configuration surface: the shapes every later wave's
//! constructors take (serve-config, dial-config, [`Limits`]). Types //! constructors take (serve-config, dial-config, [`Limits`]). Types
//! only: russh `Config` construction is wave-2 material, dialing lands //! only: russh `Config` construction is wave-2 material, dialing lands
//! at wave 4; nothing here touches the wire or the vault. //! at wave 4; nothing here touches the wire or the vault.
//! //!
//! Two directions, one budget model (`overview.md` §Public surface): //! Two directions, one budget model (`overview.md` §Public surface):
//! //!
//! - [`ServeConfig`] — the server direction's assembly-supplied serving //! - [`ServeConfig`]: the server direction's assembly-supplied serving
//! configuration: the OQ-08 fields ([`ServeConfig::shell_argv`] / //! configuration: the OQ-08 fields ([`ServeConfig::shell_argv`] /
//! [`ServeConfig::exec_wrapper_argv`]), the injected identity, //! [`ServeConfig::exec_wrapper_argv`]), the injected identity,
//! host-key and tunnel handles, the per-feature family-seam handles //! host-key and tunnel handles, the per-feature family-seam handles
//! (the wave-3 extension — [`SessionSeam`] carries the alktty //! (the wave-3 extension, where [`SessionSeam`] carries the alktty
//! backends map + backend selector + ownership provider; //! backends map + backend selector + ownership provider;
//! [`GitSeam`] carries the alkgit backend quartet + the caller-owned //! [`GitSeam`] carries the alkgit backend quartet + the caller-owned
//! pipeline + the push-options flag), the OQ-09 `cwd`/`env` values, //! pipeline + the push-options flag), the OQ-09 `cwd`/`env` values,
//! and the [`Limits`]. //! and the [`Limits`].
//! - [`DialConfig`] — the client direction's dial configuration //! - [`DialConfig`]: the client direction's dial configuration
//! (`client.md` §The dial surface): address, `username`, key source, //! (`client.md` §The dial surface): address, `username`, key source,
//! host-key policy, and the bounded-resource knobs (carried by //! host-key policy, and the bounded-resource knobs (carried by
//! [`Limits`]). //! [`Limits`]).
@@ -22,7 +22,7 @@
//! Crate rules this module encodes: //! Crate rules this module encodes:
//! //!
//! - **Unset-declines, no crate defaults** (OQ-08): the OQ-08 fields //! - **Unset-declines, no crate defaults** (OQ-08): the OQ-08 fields
//! are `Option` and their unset semantics live in the type shape — //! are `Option` and their unset semantics live in the type shape,
//! an unset field makes its row decline with a clean error at //! an unset field makes its row decline with a clean error at
//! request time (wave-2/3 consumption), never a fallback, never //! request time (wave-2/3 consumption), never a fallback, never
//! construction-time validation (`error.rs`'s no-config-error //! construction-time validation (`error.rs`'s no-config-error
@@ -33,10 +33,10 @@
//! [`ServeConfig::git_seam`] unset → the git row declines even //! [`ServeConfig::git_seam`] unset → the git row declines even
//! though `git` is on (the decline matrix's config-driven notes; //! though `git` is on (the decline matrix's config-driven notes;
//! the feature alone configures nothing). The tunnel branch carries //! the feature alone configures nothing). The tunnel branch carries
//! its flat `Option<Arc<ChannelClient>>` — an egress-only //! its flat `Option<Arc<ChannelClient>>`, because an egress-only
//! (route-1-only) door needs no struct for leg 1 — and the `-R` //! (route-1-only) door needs no struct for leg 1, and the `-R`
//! listen leg's registration seam landed with `w3-tunnel-listen` //! listen leg's registration seam landed with `w3-tunnel-listen`
//! (`ServeConfig::tunnel_listen_seam` — the wave-3 seam-config's //! (`ServeConfig::tunnel_listen_seam`, the wave-3 seam-config's
//! second course-correction; the shape decision recorded on //! second course-correction; the shape decision recorded on
//! [`TunnelListenSeam`]: unset → the listen rows decline even with //! [`TunnelListenSeam`]: unset → the listen rows decline even with
//! a fabric leg present, because the door-side inbound-op serving //! a fabric leg present, because the door-side inbound-op serving
@@ -48,17 +48,17 @@
//! no vault lifecycle of its own. A git-only tunnel build simply //! no vault lifecycle of its own. A git-only tunnel build simply
//! leaves [`ServeConfig::channel_client`] absent. //! leaves [`ServeConfig::channel_client`] absent.
//! - **Bounded resources everywhere** (convention 9): every session //! - **Bounded resources everywhere** (convention 9): every session
//! — both directions — carries a [`Limits`]; there is no //! in both directions carries a [`Limits`]; there is no
//! unbounded-loop or unbounded-buffer configuration shape the //! unbounded-loop or unbounded-buffer configuration shape the
//! assembly can build from these types. //! assembly can build from these types.
//! //!
//! Knob-name parity with the russh 0.65 pin (ADR-002): //! Knob-name parity with the russh 0.65 pin (ADR-002):
//! `window_size`, `maximum_packet_size`, `channel_buffer_size`, //! `window_size`, `maximum_packet_size`, `channel_buffer_size`,
//! `inactivity_timeout`, `keepalive_interval`, `keepalive_max`, //! `inactivity_timeout`, `keepalive_interval`, `keepalive_max`,
//! `max_auth_attempts` — wave-2/wave-4 map these fields into the russh //! `max_auth_attempts`; wave-2/wave-4 map these fields into the russh
//! `Config` verbatim. russh's rekey `Limits` stay russh-enforced at //! `Config` verbatim. russh's rekey `Limits` stay russh-enforced at
//! its own RFC-4253 §9 defaults (wave-2); they are not carried here — //! 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 //! since this module's [`Limits`] is the crate's in-crate + mapped-knob
//! budget model. //! budget model.
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -84,15 +84,15 @@ use crate::key_source::{HostKeySource, KeySource};
/// The bounded-resources budget model (`overview.md` /// The bounded-resources budget model (`overview.md`
/// §Bounded-resources policy): the in-crate caps plus the russh-knob /// §Bounded-resources policy): the in-crate caps plus the russh-knob
/// mapping with the documented default postures. Every session — /// mapping with the documented default postures. Every session,
/// server and client direction — carries one (`AGENTS.md` convention /// server or client direction, carries one (`AGENTS.md` convention
/// 9); there is no unbounded configuration shape. /// 9); there is no unbounded configuration shape.
/// ///
/// Carried by value (`Copy`): a [`Limits`] is plain tuning data, and /// Carried by value (`Copy`): a [`Limits`] is plain tuning data, and
/// wave-2/4 copies it into the russh `Config` construction. /// wave-2/4 copies it into the russh `Config` construction.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Limits { pub struct Limits {
/// The crates' own cap on concurrent channels per session — /// The crates' own cap on concurrent channels per session;
/// opened channels beyond the cap are declined (fail-closed /// opened channels beyond the cap are declined (fail-closed
/// refuse, never queue). The per-identity policy layer (when the /// refuse, never queue). The per-identity policy layer (when the
/// alkcall bridge carries it) is additional and comes with the /// alkcall bridge carries it) is additional and comes with the
@@ -102,39 +102,39 @@ pub struct Limits {
pub max_concurrent_channels: usize, pub max_concurrent_channels: usize,
/// The parse-input cap for the wave-3 exec grammar: a wire exec /// The parse-input cap for the wave-3 exec grammar: a wire exec
/// command string longer than this declines before any parse or /// command string longer than this declines before any parse or
/// resource allocation (decline-first, alloc-never — the ADR-006 /// resource allocation (decline-first, alloc-never, the ADR-006
/// dispatch contract). Assembly-tunable. /// dispatch contract). Assembly-tunable.
/// ///
/// Default: `DEFAULT_MAX_EXEC_COMMAND_LEN`. /// Default: `DEFAULT_MAX_EXEC_COMMAND_LEN`.
pub max_exec_command_len: usize, pub max_exec_command_len: usize,
/// Maximal number of allowed authentication attempts — mapped to /// Maximal number of allowed authentication attempts, mapped to
/// russh's `max_auth_attempts` (server direction). Default `10` /// russh's `max_auth_attempts` (server direction). Default `10`
/// per ADR-004 decision 6: the OpenSSH counting `none`-probe /// per ADR-004 decision 6: the OpenSSH counting `none`-probe
/// consumes one attempt, so `10` leaves `IdentitiesOnly`-style /// consumes one attempt, so `10` leaves `IdentitiesOnly`-style
/// clients at least nine real key attempts. /// clients at least nine real key attempts.
pub max_auth_attempts: usize, pub max_auth_attempts: usize,
/// The initial channel receive window — mapped to russh's /// The initial channel receive window, mapped to russh's
/// `window_size` (per-channel memory bound). /// `window_size` (per-channel memory bound).
/// ///
/// Default: 2 MiB (russh's own default posture, kept). /// Default: 2 MiB (russh's own default posture, kept).
pub window_size: u32, pub window_size: u32,
/// The maximal size of a single SSH packet — mapped to russh's /// The maximal size of a single SSH packet, mapped to russh's
/// `maximum_packet_size` (per-channel record-size bound). /// `maximum_packet_size` (per-channel record-size bound).
/// ///
/// Default: 32 KiB (russh's own default posture, kept). /// Default: 32 KiB (russh's own default posture, kept).
pub maximum_packet_size: u32, pub maximum_packet_size: u32,
/// Per-channel unprocessed-message buffer — mapped to russh's /// Per-channel unprocessed-message buffer, mapped to russh's
/// `channel_buffer_size` (the backpressure bound); messages past /// `channel_buffer_size` (the backpressure bound); messages past
/// it propagate backpressure to the TCP stream, never an /// it propagate backpressure to the TCP stream, never an
/// unbounded buffer. /// unbounded buffer.
/// ///
/// Default: 100 (russh's own default posture, kept). /// Default: 100 (russh's own default posture, kept).
pub channel_buffer_size: usize, pub channel_buffer_size: usize,
/// The session wall clock — the connection is garbage-collected /// The session wall clock; the connection is garbage-collected
/// after this long with no activity. Mapped to russh's /// after this long with no activity. Mapped to russh's
/// `Option<inactivity_timeout>` as always-set (`Some(value)`) in /// `Option<inactivity_timeout>` as always-set (`Some(value)`) in
/// wave 2/4: the bounded-resources policy's "set /// wave 2/4: the bounded-resources policy's "set
/// (assembly-tunable)" posture — the crate never constructs a /// (assembly-tunable)" posture; the crate never constructs a
/// session with no wall clock. Assembly-tunable. /// session with no wall clock. Assembly-tunable.
/// ///
/// Default: `DEFAULT_INACTIVITY_TIMEOUT`. /// Default: `DEFAULT_INACTIVITY_TIMEOUT`.
@@ -142,13 +142,13 @@ pub struct Limits {
/// Client-direction keepalive interval (cloud fleets): the /// Client-direction keepalive interval (cloud fleets): the
/// dead-peer probe, mapped to russh's `keepalive_interval`. /// dead-peer probe, mapped to russh's `keepalive_interval`.
/// ///
/// Default: `Some(15 s)` — the reverse-runner-evidence posture /// Default: `Some(15 s)`, the reverse-runner-evidence posture
/// (OpenSSH `ServerAliveInterval=15` parity): with russh's own /// (OpenSSH `ServerAliveInterval=15` parity): with russh's own
/// default (`None`) a dead NAT'd connection sits unnoticed until /// default (`None`) a dead NAT'd connection sits unnoticed until
/// an exec health check fails (`reverse-runner.md` lesson 3). /// an exec health check fails (`reverse-runner.md` lesson 3).
/// `None` is available to the assembly but is not the posture. /// `None` is available to the assembly but is not the posture.
pub keepalive_interval: Option<Duration>, pub keepalive_interval: Option<Duration>,
/// How many unanswered keepalives close the connection — mapped /// How many unanswered keepalives close the connection, mapped
/// to russh's `keepalive_max`. /// to russh's `keepalive_max`.
/// ///
/// Default: `3` (OpenSSH `ServerAliveCountMax=3` parity, the /// Default: `3` (OpenSSH `ServerAliveCountMax=3` parity, the
@@ -202,32 +202,32 @@ impl Default for Limits {
/// The `session` feature's alktty seam handle (`mapping.md` §The /// The `session` feature's alktty seam handle (`mapping.md` §The
/// seams): what `drive_session[_pre_negotiated]` takes that the /// seams): what `drive_session[_pre_negotiated]` takes that the
/// assembly composes (alktty 0.8.1's `adapter.rs:205-241` signature). /// assembly composes (alktty 0.8.2's `adapter.rs:205-241` signature).
/// **Shape decision** (recorded at `w3-seam-config`): a whole-`Option` /// **Shape decision** (recorded at `w3-seam-config`): a whole-`Option`
/// struct per seam, not flat `Option` fields — each seam is consumed /// struct per seam, not flat `Option` fields; each seam is consumed
/// atomically by exactly one seam call, so a struct admits no /// atomically by exactly one seam call, so a struct admits no
/// half-supplied seam states and the unset-declines contract reads as /// half-supplied seam states and the unset-declines contract reads as
/// one `Option`: **`ServeConfig::session_seam` unset → the session /// one `Option`: **`ServeConfig::session_seam` unset → the session
/// rows decline even though the `session` feature is on** (the /// rows decline even though the `session` feature is on** (the
/// decline matrix's config-driven note), checked at request time by /// decline matrix's config-driven note), checked at request time by
/// the mappings — no construction-time validation. Cloned into each /// the mappings; no construction-time validation. Cloned into each
/// spawned serving task (all payload types `Arc` or plain data). /// spawned serving task (all payload types `Arc` or plain data).
#[cfg(feature = "session")] #[cfg(feature = "session")]
#[derive(Clone)] #[derive(Clone)]
pub struct SessionSeam { pub struct SessionSeam {
/// The alktty backends map — the `TtyAdapter`-style registry keyed /// The alktty backends map, the `TtyAdapter`-style registry keyed
/// by the `NegotiateRequest.backend` selector string. **Assembled /// by the `NegotiateRequest.backend` selector string. **Assembled
/// by the assembly**: alkssh never touches a real backend, and the /// by the assembly**: alkssh never touches a real backend, and the
/// `local` backend shapes stay alktty-side (this dep's `local` /// `local` backend shapes stay alktty-side (this dep's `local`
/// feature is not enabled — the map holds assembly-supplied /// feature is not enabled; the map holds assembly-supplied
/// implementations of alktty's [`TtyBackend`] only). /// implementations of alktty's [`TtyBackend`] only).
pub backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>, pub backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>,
/// The backend **selector** — the `NegotiateRequest.backend` /// The backend **selector**, the `NegotiateRequest.backend`
/// string alkssh writes into the locally-constructed negotiation /// string alkssh writes into the locally-constructed negotiation
/// (`w3-tty-session`'s negotiation rows; the wire command/args /// (`w3-tty-session`'s negotiation rows; the wire command/args
/// never reach it). Assembly-supplied, never wire-derived. /// never reach it). Assembly-supplied, never wire-derived.
pub backend: String, pub backend: String,
/// The ADR-050 ownership provider — passed through /// The ADR-050 ownership provider, passed through
/// `drive_session[_pre_negotiated]` verbatim (the family ACL /// `drive_session[_pre_negotiated]` verbatim (the family ACL
/// layer riding alongside; an alkcall type, no new dep). /// layer riding alongside; an alkcall type, no new dep).
pub ownership: Option<Arc<dyn OwnershipProvider>>, pub ownership: Option<Arc<dyn OwnershipProvider>>,
@@ -248,27 +248,27 @@ impl std::fmt::Debug for SessionSeam {
/// The `git` feature's alkgit seam handle (`mapping.md` §The seams): /// The `git` feature's alkgit seam handle (`mapping.md` §The seams):
/// what `serve_duplex_resolved` takes that the assembly composes /// what `serve_duplex_resolved` takes that the assembly composes
/// (alkgit 0.1.0's `duplex.rs:1412-1426` signature — the session /// (alkgit 0.1.0's `duplex.rs:1412-1426` signature: the session
/// budget, the four backend traits, the caller-owned pipeline, the /// budget, the four backend traits, the caller-owned pipeline, the
/// push-options gate; the `limits` field landed with `w3-git-mapping` /// push-options gate; the `limits` field landed with `w3-git-mapping`
/// — the first seam consumer — as a `w3-seam-config` course /// the first seam consumer, as a `w3-seam-config` course
/// correction: alkgit's ADR-009 budget is assembly config, the door /// correction: alkgit's ADR-009 budget is assembly config, the door
/// never invents it). Same whole-`Option`-struct decision as /// never invents it). Same whole-`Option`-struct decision as
/// [`SessionSeam`]: /// [`SessionSeam`]:
/// **`ServeConfig::git_seam` unset → the git row declines even though /// **`ServeConfig::git_seam` unset → the git row declines even though
/// the `git` feature is on**, checked at request time by the /// the `git` feature is on**, checked at request time by the
/// mappings — no construction-time validation. /// mappings; no construction-time validation.
#[cfg(feature = "git")] #[cfg(feature = "git")]
#[derive(Clone)] #[derive(Clone)]
pub struct GitSeam { pub struct GitSeam {
/// The alkgit session budget (alkgit ADR-009's per-session bundle — /// The alkgit session budget (alkgit ADR-009's per-session bundle:
/// wall clock, pack-size caps, negotiation rounds): **constructed /// wall clock, pack-size caps, negotiation rounds): **constructed
/// by the assembly** (alkgit's own contract — the door never /// by the assembly** (alkgit's own contract: the door never
/// invents budget values), passed into `serve_duplex_resolved` /// invents budget values), passed into `serve_duplex_resolved`
/// verbatim (`w3-git-mapping`). Carried `Copy`; [`alkgit::Limits`]'s /// verbatim (`w3-git-mapping`). Carried `Copy`; [`alkgit::Limits`]'s
/// own defaults are the assembly's baseline. /// own defaults are the assembly's baseline.
pub limits: alkgit::Limits, pub limits: alkgit::Limits,
/// The repo-id resolution trait (`GitRegistry::resolve`) — the /// The repo-id resolution trait (`GitRegistry::resolve`), the
/// ADR-007 pre-protocol gate's other half; alkssh never resolves /// ADR-007 pre-protocol gate's other half; alkssh never resolves
/// (the collapsed-generic-denial posture lives in alkgit). /// (the collapsed-generic-denial posture lives in alkgit).
pub registry: Arc<dyn GitRegistry>, pub registry: Arc<dyn GitRegistry>,
@@ -278,13 +278,13 @@ pub struct GitSeam {
/// The pack generation + negotiation-ack trait /// The pack generation + negotiation-ack trait
/// (`generate`/`common_haves`). /// (`generate`/`common_haves`).
pub packgen: Arc<dyn GitPackGen>, pub packgen: Arc<dyn GitPackGen>,
/// The pack-preparation trait (`prepare` — never applies; the /// The pack-preparation trait (`prepare` never applies; the
/// `GitRefs::apply_updates` CAS stays the single ingestion home). /// `GitRefs::apply_updates` CAS stays the single ingestion home).
pub ingest: Arc<dyn GitPackIngest>, pub ingest: Arc<dyn GitPackIngest>,
/// The assembler's push-options flag (alkgit's ADR-013 §11 gate, /// The assembler's push-options flag (alkgit's ADR-013 §11 gate,
/// passed through verbatim) — `false` in the v1 default. /// passed through verbatim), `false` in the v1 default.
pub push_options_negotiated: bool, pub push_options_negotiated: bool,
/// The caller-owned, server-wide pipeline permit — alkgit's /// The caller-owned, server-wide pipeline permit: alkgit's
/// pack-pipeline concurrency bound, *shared across every session /// pack-pipeline concurrency bound, *shared across every session
/// the serving direction takes* (the caller keeps its own handle; /// the serving direction takes* (the caller keeps its own handle;
/// `Arc` for the share). One per `DialConfig`-independent serve /// `Arc` for the share). One per `DialConfig`-independent serve
@@ -308,7 +308,7 @@ impl std::fmt::Debug for GitSeam {
} }
/// The `tunnel` feature's `-R` listen-leg registration seam (the /// The `tunnel` feature's `-R` listen-leg registration seam (the
/// `w3-tunnel-listen` shape — the wave-3 seam-config's second /// `w3-tunnel-listen` shape, the wave-3 seam-config's second
/// course-correction, resolved per its own decision record): the /// course-correction, resolved per its own decision record): the
/// assembly-supplied `Arc<ChannelClient>` alone cannot carry the /// assembly-supplied `Arc<ChannelClient>` alone cannot carry the
/// door-side acceptance of the fabric's inbound forwarded opens, /// door-side acceptance of the fabric's inbound forwarded opens,
@@ -324,11 +324,11 @@ impl std::fmt::Debug for GitSeam {
/// **`ServeConfig::tunnel_listen_seam` unset → the `-R` listen rows /// **`ServeConfig::tunnel_listen_seam` unset → the `-R` listen rows
/// (`tcpip_forward` / `streamlocal_forward` / both cancel forms) /// (`tcpip_forward` / `streamlocal_forward` / both cancel forms)
/// decline even under the `tunnel` feature with a fabric leg present** /// decline even under the `tunnel` feature with a fabric leg present**
/// — an egress-only door (the POC-3 stage-C posture) is legitimate /// : an egress-only door (the POC-3 stage-C posture) is legitimate
/// config; the feature alone configures nothing. Checked at request /// config; the feature alone configures nothing. Checked at request
/// time by the mappings; no construction-time validation. The /// time by the mappings; no construction-time validation. The
/// registration itself (the alkcall openable registration the seam /// registration itself (the alkcall openable registration the seam
/// fronts) is alkssh-side work at [`crate::SshAdapter::new`] — a /// fronts) is alkssh-side work at [`crate::SshAdapter::new`]; a
/// failed registration is a failed construction (fail-closed, never a /// failed registration is a failed construction (fail-closed, never a
/// half-configured adapter). /// half-configured adapter).
#[cfg(feature = "tunnel")] #[cfg(feature = "tunnel")]
@@ -336,7 +336,7 @@ pub struct TunnelListenSeam {
/// The **shared** per-connection `OperationRegistry` the fabric /// The **shared** per-connection `OperationRegistry` the fabric
/// leg's inbound ops dispatch against: the exact `Arc` the /// leg's inbound ops dispatch against: the exact `Arc` the
/// assembly passed in this leg's `ChannelClient` construction /// assembly passed in this leg's `ChannelClient` construction
/// (`ServingConfig.registry`) — alkssh registers the /// (`ServingConfig.registry`): alkssh registers the
/// `tunnel/forwarded` openable into it, and alkcall's dispatch /// `tunnel/forwarded` openable into it, and alkcall's dispatch
/// reads it per call (the W2 post-hoc-registration posture). The /// reads it per call (the W2 post-hoc-registration posture). The
/// door's own ACL entry rides the registration; arrivals lacking /// door's own ACL entry rides the registration; arrivals lacking
@@ -355,7 +355,7 @@ use alkcall::registry::registration::OperationRegistry;
impl TunnelListenSeam { impl TunnelListenSeam {
/// Builds the seam over the assembly's shared per-connection /// Builds the seam over the assembly's shared per-connection
/// registry (the `ServingConfig.registry` the same fabric leg's /// registry (the `ServingConfig.registry` the same fabric leg's
/// `ChannelClient` was constructed with — one `Arc`, both sides). /// `ChannelClient` was constructed with: one `Arc`, both sides).
pub fn new(registry: Arc<OperationRegistry>) -> Self { pub fn new(registry: Arc<OperationRegistry>) -> Self {
Self { registry } Self { registry }
} }
@@ -372,22 +372,22 @@ impl std::fmt::Debug for TunnelListenSeam {
/// The server direction's assembly-supplied serving configuration /// The server direction's assembly-supplied serving configuration
/// (`overview.md` §Server stack): everything wave-2's serving loop /// (`overview.md` §Server stack): everything wave-2's serving loop
/// takes at construction. Pure data + injected handles — no russh /// takes at construction. Pure data + injected handles; no russh
/// `Config` is built here (wave 2), no serving loop exists yet. /// `Config` is built here (wave 2), no serving loop exists yet.
/// ///
/// Construction carries no validation beyond the required handles: /// Construction carries no validation beyond the required handles:
/// the unfeatured/unset capability surfaces are structural `Option`s /// the unfeatured/unset capability surfaces are structural `Option`s
/// that decline at request time (the fail-closed contract) — see /// that decline at request time (the fail-closed contract); see
/// [`ServeConfig::shell_argv`] and [`ServeConfig::channel_client`]. /// [`ServeConfig::shell_argv`] and [`ServeConfig::channel_client`].
pub struct ServeConfig { pub struct ServeConfig {
/// OQ-08 field 1: the shell argv, run verbatim for /// OQ-08 field 1: the shell argv, run verbatim for
/// `shell_request` (`NegotiateRequest.cmd`). No crate default — /// `shell_request` (`NegotiateRequest.cmd`). No crate default:
/// **unset → `shell_request` declines with a clean error, never a /// **unset → `shell_request` declines with a clean error, never a
/// fallback**: the crate is not an sshd and has no account model /// fallback**: the crate is not an sshd and has no account model
/// from which a shell could be derived, so any baked-in shell /// from which a shell could be derived, so any baked-in shell
/// would be deployment policy smuggled into the crate. A /// would be deployment policy smuggled into the crate. A
/// `session` build with this unset serves neither shell nor exec /// `session` build with this unset serves neither shell nor exec
/// requests (the git row is unaffected — parsing never needs a /// requests (the git row is unaffected, since parsing never needs a
/// shell). /// shell).
/// ///
/// Set to `Some(vec)` to serve the shell row (feature-gated at /// Set to `Some(vec)` to serve the shell row (feature-gated at
@@ -396,33 +396,33 @@ pub struct ServeConfig {
/// OQ-08 field 2: the exec wrapper argv (platform-neutral, e.g. /// OQ-08 field 2: the exec wrapper argv (platform-neutral, e.g.
/// `["/bin/sh", "-c"]`). A non-git `exec_request`'s wire command /// `["/bin/sh", "-c"]`). A non-git `exec_request`'s wire command
/// string is appended as the final argv element /// string is appended as the final argv element
/// (`wrapper ++ [cmd]`) — vanilla `ssh host "cmd"` quoting /// (`wrapper ++ [cmd]`), the vanilla `ssh host "cmd"` quoting
/// semantics; command strings are never naively split. No crate /// semantics; command strings are never naively split. No crate
/// default — **unset → the non-git exec row declines with a /// default: **unset → the non-git exec row declines with a
/// clean error, never a fallback**. /// clean error, never a fallback**.
/// ///
/// Set to `Some(vec)` to serve the pipe-exec row (feature-gated /// Set to `Some(vec)` to serve the pipe-exec row (feature-gated
/// at wave 2: only under the `session` feature). /// at wave 2: only under the `session` feature).
pub exec_wrapper_argv: Option<Vec<String>>, pub exec_wrapper_argv: Option<Vec<String>>,
/// The OQ-09 posture's assembly-supplied working directory for /// The OQ-09 posture's assembly-supplied working directory for
/// alktty `NegotiateRequest.cwd` — **never wire-derived** /// alktty `NegotiateRequest.cwd`, **never wire-derived**
/// (`env_request` declines unconditionally; wire env is not /// (`env_request` declines unconditionally; wire env is not
/// family config). Unset → the backend-side default applies. /// family config). Unset → the backend-side default applies.
pub session_cwd: Option<PathBuf>, pub session_cwd: Option<PathBuf>,
/// The OQ-09 posture's assembly-supplied environment for /// The OQ-09 posture's assembly-supplied environment for
/// `NegotiateRequest.env` — **never wire-derived**. Empty by /// `NegotiateRequest.env`, **never wire-derived**. Empty by
/// default. /// default.
pub session_env: BTreeMap<String, String>, pub session_env: BTreeMap<String, String>,
/// The `session` feature's alktty seam handle ([`SessionSeam`] — /// The `session` feature's alktty seam handle ([`SessionSeam`],
/// the backends map + backend selector + ownership provider the /// the backends map + backend selector + ownership provider the
/// `drive_session[_pre_negotiated]` rows take). **Unset → the /// `drive_session[_pre_negotiated]` rows take). **Unset → the
/// session rows decline even though the `session` feature is on** /// session rows decline even though the `session` feature is on**
/// (the decline matrix's config-driven note; the feature alone /// (the decline matrix's config-driven note; the feature alone
/// configures nothing). Wave-3 extension of the wave-1 config /// configures nothing). Wave-3 extension of the wave-1 config
/// surface — shape decision recorded on [`SessionSeam`]. /// surface; shape decision recorded on [`SessionSeam`].
#[cfg(feature = "session")] #[cfg(feature = "session")]
pub session_seam: Option<SessionSeam>, pub session_seam: Option<SessionSeam>,
/// The `git` feature's alkgit seam handle ([`GitSeam`] — the four /// The `git` feature's alkgit seam handle ([`GitSeam`], the four
/// backend traits + the caller-owned pipeline + the push-options /// backend traits + the caller-owned pipeline + the push-options
/// flag the `serve_duplex_resolved` row takes). **Unset → the git /// flag the `serve_duplex_resolved` row takes). **Unset → the git
/// row declines even though the `git` feature is on** (same /// row declines even though the `git` feature is on** (same
@@ -430,33 +430,33 @@ pub struct ServeConfig {
#[cfg(feature = "git")] #[cfg(feature = "git")]
pub git_seam: Option<GitSeam>, pub git_seam: Option<GitSeam>,
/// The injected identity provider (alkcall's /// The injected identity provider (alkcall's
/// `IdentityProvider::resolve_from_fingerprint`) — the assembly /// `IdentityProvider::resolve_from_fingerprint`), the assembly
/// supplies it; the crate never resolves identities itself and /// supplies it; the crate never resolves identities itself and
/// holds no identity model. /// holds no identity model.
pub identity_provider: Arc<dyn IdentityProvider>, pub identity_provider: Arc<dyn IdentityProvider>,
/// The injected host-key source (the wave-1 /// The injected host-key source (the wave-1
/// [`HostKeySource`] seam, decrypt-at-construction per ADR-005 /// [`HostKeySource`] seam, decrypt-at-construction per ADR-005
/// decision 6) — feeds wave-2's `Config::keys`; a load failure /// decision 6) feeds wave-2's `Config::keys`; a load failure
/// means no server is constructed, never a half-configured one. /// means no server is constructed, never a half-configured one.
pub host_key_source: Arc<dyn HostKeySource>, pub host_key_source: Arc<dyn HostKeySource>,
/// The tunnel route-1 bridge's family-fabric entry: a held /// The tunnel route-1 bridge's family-fabric entry: a held
/// alkcall `ChannelClient` (`TunnelSession::open_direct` runs on /// alkcall `ChannelClient` (`TunnelSession::open_direct` runs on
/// it). **Absent → every tunnel row declines with a clean error /// it). **Absent → every tunnel row declines with a clean error
/// even under the `tunnel` feature** (the feature alone configures /// even under the `tunnel` feature** (the feature alone configures
/// nothing — the fabric handle is the row's other half). The /// nothing; the fabric handle is the row's other half). The
/// present handle is still gated by the `tunnel` feature at wave /// present handle is still gated by the `tunnel` feature at wave
/// 2: absent + unfeatured both decline; present + featured /// 2: absent + unfeatured both decline; present + featured
/// serves. /// serves.
pub channel_client: Option<Arc<ChannelClient>>, pub channel_client: Option<Arc<ChannelClient>>,
/// The `-R` listen leg's registration seam ([`TunnelListenSeam`] — /// The `-R` listen leg's registration seam ([`TunnelListenSeam`],
/// the shared per-connection registry the fabric's inbound /// the shared per-connection registry the fabric's inbound
/// forwarded opens dispatch against). **Unset → the listen rows /// forwarded opens dispatch against). **Unset → the listen rows
/// decline even under the `tunnel` feature with a fabric leg /// decline even under the `tunnel` feature with a fabric leg
/// present** (the egress-only door is legitimate config — the /// present** (the egress-only door is legitimate config, the
/// POC-3 stage-C posture; shape decision recorded on the type). /// POC-3 stage-C posture; shape decision recorded on the type).
#[cfg(feature = "tunnel")] #[cfg(feature = "tunnel")]
pub tunnel_listen_seam: Option<TunnelListenSeam>, pub tunnel_listen_seam: Option<TunnelListenSeam>,
/// The listen leg's per-arrival bookkeeping — installed by /// The listen leg's per-arrival bookkeeping, installed by
/// [`crate::SshAdapter::new`] when the fabric leg + seam are both /// [`crate::SshAdapter::new`] when the fabric leg + seam are both
/// present (the registration site). Crate-private: never assembly /// present (the registration site). Crate-private: never assembly
/// data. /// data.
@@ -469,10 +469,10 @@ pub struct ServeConfig {
impl ServeConfig { impl ServeConfig {
/// Builds a serve-config from the two injected handles the /// Builds a serve-config from the two injected handles the
/// serving direction can never default — the identity provider /// serving direction can never default, the identity provider
/// and the host-key source. The OQ-08 fields start unset (their /// and the host-key source. The OQ-08 fields start unset (their
/// rows decline), the tunnel handle starts absent (its rows /// rows decline), the tunnel handle starts absent (its rows
/// decline), and `limits` starts at [`Limits::default()`] — /// decline), and `limits` starts at [`Limits::default()`],
/// override the `pub` fields from the assembly. /// override the `pub` fields from the assembly.
pub fn new( pub fn new(
identity_provider: Arc<dyn IdentityProvider>, identity_provider: Arc<dyn IdentityProvider>,
@@ -500,14 +500,14 @@ impl ServeConfig {
/// Whether the `shell_request` row is configurable at all in this /// Whether the `shell_request` row is configurable at all in this
/// configuration (`Some` under the `session` feature): `false` /// configuration (`Some` under the `session` feature): `false`
/// means the row declines — the unset-declines contract, checked /// means the row declines; the unset-declines contract, checked
/// at request time by the wave-2 dispatch, never here. /// at request time by the wave-2 dispatch, never here.
pub fn shell_configured(&self) -> bool { pub fn shell_configured(&self) -> bool {
self.shell_argv.is_some() self.shell_argv.is_some()
} }
/// Whether the non-git `exec_request` (pipe) row is configurable /// Whether the non-git `exec_request` (pipe) row is configurable
/// at all in this configuration: `false` means the row declines — /// at all in this configuration: `false` means the row declines;
/// the unset-declines contract, checked at request time by the /// the unset-declines contract, checked at request time by the
/// wave-2 dispatch, never here. /// wave-2 dispatch, never here.
pub fn exec_wrapper_configured(&self) -> bool { pub fn exec_wrapper_configured(&self) -> bool {
@@ -524,7 +524,7 @@ impl ServeConfig {
/// Whether the `-R` listen rows' registration seam is configured /// Whether the `-R` listen rows' registration seam is configured
/// in this configuration ([`TunnelListenSeam`] present; the /// in this configuration ([`TunnelListenSeam`] present; the
/// `tunnel` feature gate applies at the dispatch on top): `false` /// `tunnel` feature gate applies at the dispatch on top): `false`
/// means the listen rows decline — the unset-declines contract, /// means the listen rows decline; the unset-declines contract,
/// checked at request time by the mappings, never here. /// checked at request time by the mappings, never here.
#[cfg(feature = "tunnel")] #[cfg(feature = "tunnel")]
pub fn tunnel_listen_seam_configured(&self) -> bool { pub fn tunnel_listen_seam_configured(&self) -> bool {
@@ -534,7 +534,7 @@ impl ServeConfig {
/// Whether the session rows' family seam is configured in this /// Whether the session rows' family seam is configured in this
/// configuration ([`SessionSeam`] present; the `session` feature /// configuration ([`SessionSeam`] present; the `session` feature
/// gate applies at the dispatch on top): `false` means the rows /// gate applies at the dispatch on top): `false` means the rows
/// decline — the unset-declines contract, checked at request time /// decline; the unset-declines contract, checked at request time
/// by the wave-3 mappings, never here. /// by the wave-3 mappings, never here.
#[cfg(feature = "session")] #[cfg(feature = "session")]
pub fn session_seam_configured(&self) -> bool { pub fn session_seam_configured(&self) -> bool {
@@ -543,8 +543,8 @@ impl ServeConfig {
/// Whether the git row's family seam is configured in this /// Whether the git row's family seam is configured in this
/// configuration ([`GitSeam`] present; the `git` feature gate /// configuration ([`GitSeam`] present; the `git` feature gate
/// applies at the dispatch on top): `false` means the row declines /// applies at the dispatch on top): `false` means the row declines;
/// — the unset-declines contract, checked at request time by the /// the unset-declines contract, checked at request time by the
/// wave-3 mappings, never here. /// wave-3 mappings, never here.
#[cfg(feature = "git")] #[cfg(feature = "git")]
pub fn git_seam_configured(&self) -> bool { pub fn git_seam_configured(&self) -> bool {
@@ -581,21 +581,21 @@ impl std::fmt::Debug for ServeConfig {
/// The client direction's dial configuration (`client.md` §The dial /// The client direction's dial configuration (`client.md` §The dial
/// surface): everything wave-4's dial surface takes. Pure data + /// surface): everything wave-4's dial surface takes. Pure data +
/// injected handles — the russh client `Config` construction and the /// injected handles; the russh client `Config` construction and the
/// dial itself live in the crate's dial module (the `w4-dial` /// dial itself live in the crate's dial module (the `w4-dial`
/// surface). /// surface).
/// ///
/// Construction carries no validation: the key source's own /// Construction carries no validation: the key source's own
/// load path is fail-closed ([`KeySource`] — locked/absent/decryption /// load path is fail-closed ([`KeySource`]: locked/absent/decryption
/// failures are all no-dial outcomes), and the host-key policy's /// failures are all no-dial outcomes), and the host-key policy's
/// default is fail-closed ([crate::host_key::RejectAll]). /// default is fail-closed ([crate::host_key::RejectAll]).
/// ///
/// **The `username` field** (the task's sanctioned /// **The `username` field** (the task's sanctioned
/// `w3-seam-config`-pattern course-correction — the only amendment the /// `w3-seam-config`-pattern course-correction, the only amendment the
/// wave-1 shape needed): the USERAUTH_REQUEST's user string. The /// wave-1 shape needed): the USERAUTH_REQUEST's user string. The
/// wave-1 shape carried everything `client.md` §The dial surface names /// wave-1 shape carried everything `client.md` §The dial surface names
/// but structurally missed this one: russh's `authenticate_publickey` /// but structurally missed this one: russh's `authenticate_publickey`
/// requires a user, and the remote end evaluates it — the family /// requires a user, and the remote end evaluates it; the family
/// identity model stays fingerprint-only forever (`auth.md` §Host-key; /// identity model stays fingerprint-only forever (`auth.md` §Host-key;
/// the username is not identity and carries no routing semantics, /// the username is not identity and carries no routing semantics,
/// OQ-03), so this field is pure dial data the assembly supplies. No /// OQ-03), so this field is pure dial data the assembly supplies. No
@@ -603,28 +603,28 @@ impl std::fmt::Debug for ServeConfig {
/// smuggled into the crate); an empty string is accepted as data and /// smuggled into the crate); an empty string is accepted as data and
/// is the remote end's to refuse. /// is the remote end's to refuse.
pub struct DialConfig { pub struct DialConfig {
/// The dial target — `host:port` as russh's connect surface /// The dial target: `host:port` as russh's connect surface
/// takes it (the reverse-runner shape: vanilla `sshd` on the /// takes it (the reverse-runner shape: vanilla `sshd` on the
/// fleet box). The same address labels the host-key policy's /// fleet box). The same address labels the host-key policy's
/// presented-key decisions (host = the parsed host, port = the /// presented-key decisions (host = the parsed host, port = the
/// parsed port). /// parsed port).
pub address: String, pub address: String,
/// The user the dial authenticates as, verbatim on the /// The user the dial authenticates as, verbatim on the
/// USERAUTH_REQUEST — not identity (the fingerprint is; ADR-004 /// USERAUTH_REQUEST, not identity (the fingerprint is; ADR-004
/// decision 5's posture, mirrored client-side), never logged (the /// decision 5's posture, mirrored client-side), never logged (the
/// crate's event vocabulary carries no username). See the type /// crate's event vocabulary carries no username). See the type
/// docs. /// docs.
pub username: String, pub username: String,
/// The key source the dial authenticates with (the wave-1 /// The key source the dial authenticates with (the wave-1
/// [`KeySource`] seam) — never a static key, never env vars; /// [`KeySource`] seam): never a static key, never env vars;
/// decrypt-at-dial, fail-closed per ADR-005. /// decrypt-at-dial, fail-closed per ADR-005.
pub key_source: Arc<dyn KeySource>, pub key_source: Arc<dyn KeySource>,
/// The injected host-key policy (OQ-05) — the trust decision /// The injected host-key policy (OQ-05), the trust decision
/// surface the dial calls at russh's `check_server_key` hook. /// surface the dial calls at russh's `check_server_key` hook.
/// The crate is policy-blind; the built-in default policy /// The crate is policy-blind; the built-in default policy
/// ([crate::host_key::RejectAll]) connects to nothing, by design. /// ([crate::host_key::RejectAll]) connects to nothing, by design.
pub host_key_policy: Arc<dyn HostKeyPolicy>, pub host_key_policy: Arc<dyn HostKeyPolicy>,
/// The bounded-resource budget this dial runs under — the /// The bounded-resource budget this dial runs under; the
/// keepalive interval/max, the wall clock and the window/packet /// keepalive interval/max, the wall clock and the window/packet
/// knobs ride here (see [`Limits`]; the keepalive 15 s / 3 /// knobs ride here (see [`Limits`]; the keepalive 15 s / 3
/// default posture is the client direction's). /// default posture is the client direction's).
@@ -634,7 +634,7 @@ pub struct DialConfig {
impl DialConfig { impl DialConfig {
/// Builds a dial-config from the address, the user it /// Builds a dial-config from the address, the user it
/// authenticates as, and the two injected handles the dial /// authenticates as, and the two injected handles the dial
/// direction can never default — the key source and the host-key /// direction can never default, the key source and the host-key
/// policy. `limits` starts at [`Limits::default()`] (keepalive /// policy. `limits` starts at [`Limits::default()`] (keepalive
/// 15 s / 3 posture riding inside). /// 15 s / 3 posture riding inside).
pub fn new( pub fn new(
@@ -1033,7 +1033,7 @@ mod tests {
} }
// The wave-3 per-feature seam handles: the construction shapes, the // The wave-3 per-feature seam handles: the construction shapes, the
// unset-declines-preserved rows and the Debug posture — each block // unset-declines-preserved rows and the Debug posture; each block
// only runs in the build that carries its feature (compile-time // only runs in the build that carries its feature (compile-time
// feature isolation: no git build ever touches alktty types, // feature isolation: no git build ever touches alktty types,
// no session build ever touches alkgit types). // no session build ever touches alkgit types).
@@ -1131,7 +1131,7 @@ mod tests {
/// - *Contract stamp:* the `w3-tunnel-listen` seam shape (the /// - *Contract stamp:* the `w3-tunnel-listen` seam shape (the
/// unset-declines contract's tunnel extension): the seam starts /// unset-declines contract's tunnel extension): the seam starts
/// unset (the listen rows decline — an egress-only door is /// unset (the listen rows decline; an egress-only door is
/// legitimate config), sets independently of the fabric handle, /// legitimate config), sets independently of the fabric handle,
/// and Debug-shapes without registry internals. /// and Debug-shapes without registry internals.
#[cfg(feature = "tunnel")] #[cfg(feature = "tunnel")]
+51 -51
View File
@@ -1,75 +1,75 @@
//! The exec-grammar dispatch core (`w2-dispatch-decline-core`, full //! The exec-grammar dispatch core (`w2-dispatch-decline-core`, full
//! grammar `w3-git-mapping`) — the routing decision and the wire-decline //! grammar `w3-git-mapping`): the routing decision and the wire-decline
//! reason shaping the per-connection handler hooks speak //! reason shaping the per-connection handler hooks speak
//! ([`crate::handler`]). //! ([`crate::handler`]).
//! //!
//! **Parse-only** (ADR-006 point 3 as amended): an `exec_request` //! **Parse-only** (ADR-006 point 3 as amended): an `exec_request`
//! parses *first* — before dispatch and before any resource //! parses *first*, before dispatch and before any resource
//! allocation (**decline first, alloc never**, the POC-3 stage-A rule: //! allocation (**decline first, alloc never**, the POC-3 stage-A rule:
//! a leaked channel alloc is a capability leak). The parse is total: //! a leaked channel alloc is a capability leak). The parse is total:
//! it routes every input to exactly one of [`ExecRoute`]'s outcomes. //! it routes every input to exactly one of [`ExecRoute`]'s outcomes.
//! The *per-length limit* ([`crate::Limits::max_exec_command_len`]) is //! The *per-length limit* ([`crate::Limits::max_exec_command_len`]) is
//! consumed **before** this parse — the handler checks the wire byte //! consumed **before** this parse: the handler checks the wire byte
//! length first (pre-parse, pre-allocation), so everything below //! length first (pre-parse, pre-allocation), so everything below
//! assumes a sized input. Invalid UTF-8 parses to the pipe row //! assumes a sized input. Invalid UTF-8 parses to the pipe row
//! (parse-then-fail, the wave-2 rule). //! (parse-then-fail, the wave-2 rule).
//! //!
//! ## The exec grammar (`w3-git-mapping` — the wave-2 probe's named //! ## The exec grammar (`w3-git-mapping`, the wave-2 probe's named
//! replacement, its unit rows migrated) //! replacement, its unit rows migrated)
//! //!
//! What vanilla git sends over SSH (`git-upload-pack '/owner/demo'` — //! What vanilla git sends over SSH (`git-upload-pack '/owner/demo'`:
//! single-quoted repo path with a leading `/`; POC-3 stage A §2's //! single-quoted repo path with a leading `/`; POC-3 stage A §2's
//! verified grammar table): //! verified grammar table):
//! //!
//! - **`git-{upload,receive}-pack '<path>'`** → [`ExecRoute::Git`]: //! - **`git-{upload,receive}-pack '<path>'`** → [`ExecRoute::Git`]:
//! the two serving services of ADR-016, one separating space, and a //! the two serving services of ADR-016, one separating space, and a
//! path-shaped argument — quoted (the vanilla wire shape, fully //! path-shaped argument: quoted (the vanilla wire shape, fully
//! single-quote-wrapped) or unquoted absolute (leading `/`). The //! single-quote-wrapped) or unquoted absolute (leading `/`). The
//! path is normalized: quotes stripped, leading `/` stripped, and //! path is normalized: quotes stripped, leading `/` stripped, and
//! **one trailing `.git` suffix stripped** — the vanilla-clone idiom //! **one trailing `.git` suffix stripped** (the vanilla-clone idiom
//! (real git sends the remote URL's path verbatim, bare clones carry //! (real git sends the remote URL's path verbatim, bare clones carry
//! `.git`; the alkgit registry grammar (`validate_repo_id`) excludes //! `.git`; the alkgit registry grammar (`validate_repo_id`) excludes
//! `.`, so the door normalizes the idiom rather than asking //! `.`, so the door normalizes the idiom rather than asking
//! registries for dotted keys; an empty remainder keeps `.git` and //! registries for dotted keys; an empty remainder keeps `.git` and
//! fails validation — never silently emptied). The normalized path //! fails validation, never silently emptied). The normalized path
//! is *not yet validated* — the git row (feature-gated) validates it //! is *not yet validated*: the git row (feature-gated) validates it
//! against `alkgit::validate_repo_id` (1–2 segments, charset, ≤ 100 //! against `alkgit::validate_repo_id` (1–2 segments, charset, ≤ 100
//! bytes) and hands the id through; **alkssh never resolves** (the //! bytes) and hands the id through; **alkssh never resolves** (the
//! no-existence-oracle posture lives in alkgit's collapsed generic //! no-existence-oracle posture lives in alkgit's collapsed generic
//! denial, and an id failing the *shape* gate is the git row's //! denial, and an id failing the *shape* gate is the git row's
//! `invalid-repo-id` decline — see below). Single-segment or //! `invalid-repo-id` decline; see below). Single-segment or
//! `.git`-empty shapes ride the same grammar. //! `.git`-empty shapes ride the same grammar.
//! - **`git-send-pack` / `git-upload-archive`** → //! - **`git-send-pack` / `git-upload-archive`** →
//! [`ExecRoute::Declined`]([`ExecDecline::UnknownService`]): the //! [`ExecRoute::Declined`]([`ExecDecline::UnknownService`]): the
//! known non-serving git services take the honest decline-with-reason //! known non-serving git services take the honest decline-with-reason
//! surface (POC-3 §2; ADR-016's fixed refusal — `git-upload-archive` //! surface (POC-3 §2; ADR-016's fixed refusal: `git-upload-archive`
//! is absent from serving by ADR-016 §1). Decision recorded //! is absent from serving by ADR-016 §1). Decision recorded
//! (`w3-git-mapping`): they do **not** ride the pipe row; the decline //! (`w3-git-mapping`): they do **not** ride the pipe row; the decline
//! is build-independent (no feature ever serves them). //! is build-independent (no feature ever serves them).
//! - **A serving service with no argument at all** (service-only) → //! - **A serving service with no argument at all** (service-only) →
//! [`ExecRoute::Declined`]([`ExecDecline::ServiceOnly`]): SSH exec //! [`ExecRoute::Declined`]([`ExecDecline::ServiceOnly`]): SSH exec
//! must carry the repo (POC-3 §A — a subsystem-style name cannot //! must carry the repo (POC-3 §A: a subsystem-style name cannot
//! dispatch, so exec without a repo cannot either). //! dispatch, so exec without a repo cannot either).
//! - **Everything else** → [`ExecRoute::Pipe`] (parse-then-fail): not //! - **Everything else** → [`ExecRoute::Pipe`] (parse-then-fail): not
//! git — including path-shaped-but-not-absolute unquoted arguments //! git, including path-shaped-but-not-absolute unquoted arguments
//! (`git-upload-pack rel`, exact strings the wave-2 probe pinned) //! (`git-upload-pack rel`, exact strings the wave-2 probe pinned)
//! and unterminated quotes; the probe's routing verdicts are //! and unterminated quotes; the probe's routing verdicts are
//! preserved verbatim. //! preserved verbatim.
//! //!
//! ## The wire vocabulary (decision, recorded) //! ## The wire vocabulary (decision, recorded)
//! //!
//! SSH request replies carry no failure text, so — except for the open //! SSH request replies carry no failure text, so, except for the open
//! level — the *reason* lives in the event vocabulary //! level, the *reason* lives in the event vocabulary
//! ([`crate::observe`]), not on the wire. Decision: the grammar //! ([`crate::observe`]), not on the wire. Decision: the grammar
//! declinables (`unknown-service`, `service-only`, and the git row's //! declinables (`unknown-service`, `service-only`, and the git row's
//! `invalid-repo-id`) share the **request-level reply shape** with //! `invalid-repo-id`) share the **request-level reply shape** with
//! parse-then-fail — `channel_failure`, channel left usable (the //! parse-then-fail: `channel_failure`, channel left usable (the
//! POC-1 §5 session-survives-declines posture; POC-3's decline shape), //! POC-1 §5 session-survives-declines posture; POC-3's decline shape),
//! distinguishable only in the events. The *serving-path* shape //! distinguishable only in the events. The *serving-path* shape
//! (failure + close, the spawned task's) stays reserved for declined //! (failure + close, the spawned task's) stays reserved for declined
//! rows that owned the channel: the seam-unconfigured git row and the //! rows that owned the channel: the seam-unconfigured git row and the
//! real serve's own outcomes (the denial band alkgit writes before any //! real serve's own outcomes (the denial band alkgit writes before any
//! protocol byte, then exit-status + close — the wire shapes the //! protocol byte, then exit-status + close; the wire shapes the
//! `git_map` module pins). //! `git_map` module pins).
use russh::ChannelOpenFailure; use russh::ChannelOpenFailure;
@@ -80,7 +80,7 @@ use russh::ChannelOpenFailure;
/// `w3-git-mapping` grammar with the declined-with-reason class). /// `w3-git-mapping` grammar with the declined-with-reason class).
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ExecRoute { pub enum ExecRoute {
/// A git-parseable command — routes to the git row. The repo path /// A git-parseable command; it routes to the git row. The repo path
/// is the normalized argument grammar (quotes, leading `/`, one /// is the normalized argument grammar (quotes, leading `/`, one
/// trailing `.git`); validation (`alkgit::validate_repo_id`) is /// trailing `.git`); validation (`alkgit::validate_repo_id`) is
/// the git row's gate (the `crate::git_map` module). /// the git row's gate (the `crate::git_map` module).
@@ -88,35 +88,35 @@ pub enum ExecRoute {
service: GitServiceRoute, service: GitServiceRoute,
repo: String, repo: String,
}, },
/// Everything else — routes to the session pipe row (the /// Everything else: routes to the session pipe row (the
/// `session` feature gate is the row's other half; unfeatured /// `session` feature gate is the row's other half; unfeatured
/// builds decline here). /// builds decline here).
Pipe, Pipe,
/// A command the grammar recognizes as git-transport-shaped but /// A command the grammar recognizes as git-transport-shaped but
/// that can never dispatch on this door — declined with a reason, /// that can never dispatch on this door; it is declined with a reason,
/// honestly and build-independently (see the grammar section). /// honestly and build-independently (see the grammar section).
Declined(ExecDecline), Declined(ExecDecline),
} }
/// The git service the exec grammar recognized — the ADR-016 wire /// The git service the exec grammar recognized: the ADR-016 wire
/// strings, as the door-side form of `alkgit::Service` (the seam maps /// strings, as the door-side form of `alkgit::Service` (the seam maps
/// them verbatim; this type keeps the parse core feature-free). /// them verbatim; this type keeps the parse core feature-free).
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitServiceRoute { pub enum GitServiceRoute {
/// `git-upload-pack` — fetch. /// `git-upload-pack`: fetch.
UploadPack, UploadPack,
/// `git-receive-pack` — push. /// `git-receive-pack`: push.
ReceivePack, ReceivePack,
} }
/// A git-shaped command that can never dispatch — the decline reasons /// A git-shaped command that can never dispatch: the decline reasons
/// the grammar itself decides (the git row adds `invalid-repo-id` at /// the grammar itself decides (the git row adds `invalid-repo-id` at
/// the validation gate; that one needs `alkgit`'s grammar, this type /// the validation gate; that one needs `alkgit`'s grammar, this type
/// is the parse core's). /// is the parse core's).
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExecDecline { pub enum ExecDecline {
/// A known non-serving git service arrived on the wire: /// A known non-serving git service arrived on the wire:
/// `git-send-pack` / `git-upload-archive` (POC-3 §2 — the honest /// `git-send-pack` / `git-upload-archive` (POC-3 §2, the honest
/// surface; ADR-016's refusal). /// surface; ADR-016's refusal).
UnknownService { service: &'static str }, UnknownService { service: &'static str },
/// A serving service name with no repo argument at all (POC-3 /// A serving service name with no repo argument at all (POC-3
@@ -126,7 +126,7 @@ pub enum ExecDecline {
impl ExecDecline { impl ExecDecline {
/// The event-vocabulary reason for this decline (§the wire /// The event-vocabulary reason for this decline (§the wire
/// vocabulary — `&'static str` values only; no wire-carried /// vocabulary: `&'static str` values only; no wire-carried
/// service token ever rides an event). /// service token ever rides an event).
pub(crate) fn reason(self) -> &'static str { pub(crate) fn reason(self) -> &'static str {
match self { match self {
@@ -136,7 +136,7 @@ impl ExecDecline {
} }
} }
/// The exec-grammar parse — the wave-2 `classify_exec` probe's /// The exec-grammar parse: the wave-2 `classify_exec` probe's
/// replacement, its unit rows migrated (see the grammar section for /// replacement, its unit rows migrated (see the grammar section for
/// the exact shape rules). /// the exact shape rules).
pub fn parse_exec(command: &str) -> ExecRoute { pub fn parse_exec(command: &str) -> ExecRoute {
@@ -174,13 +174,13 @@ pub fn parse_exec(command: &str) -> ExecRoute {
} }
} }
/// The grammar's construction arm (`w4-op-rows`) — the client /// The grammar's construction arm (`w4-op-rows`), the client
/// direction's mirror of [`parse_exec`]: the git route + repo id → /// direction's mirror of [`parse_exec`]: the git route + repo id →
/// the vanilla wire command string, byte-for-byte the shape the /// the vanilla wire command string, byte-for-byte the shape the
/// grammar parses back (`git-{upload,receive}-pack '/<repo>'` — the /// grammar parses back (`git-{upload,receive}-pack '/<repo>'`, the
/// fully-single-quote-wrapped path with the leading `/`, POC-3 §A's /// fully-single-quote-wrapped path with the leading `/`, POC-3 §A's
/// verified shape). The client row reuses — never forks — the /// verified shape). The client row reuses the grammar, never forks
/// grammar: it hands the constructed command back through /// it: it hands the constructed command back through
/// [`parse_exec`] before any wire op, so the shared verdicts (the /// [`parse_exec`] before any wire op, so the shared verdicts (the
/// per-length cap, the decline classes) ride both arms /// per-length cap, the decline classes) ride both arms
/// (`client.md` §op rows: "the same exec grammar the server side /// (`client.md` §op rows: "the same exec grammar the server side
@@ -195,10 +195,10 @@ pub(crate) fn build_git_exec_command(service: GitServiceRoute, repo_id: &str) ->
} }
/// The argument-grammar stage: a path-shaped argument (fully wrapped /// The argument-grammar stage: a path-shaped argument (fully wrapped
/// in single quotes — the vanilla wire shape — or unquoted absolute; /// in single quotes, the vanilla wire shape, or unquoted absolute;
/// anything else is not git) normalized to the repo path: quotes /// anything else is not git) normalized to the repo path: quotes
/// stripped, leading `/` stripped, one trailing `.git` stripped (the /// stripped, leading `/` stripped, one trailing `.git` stripped (the
/// recorded normalization — see the module grammar section; an /// recorded normalization; see the module grammar section; an
/// empty-after-normalized id is *kept* for the git row's validation /// empty-after-normalized id is *kept* for the git row's validation
/// to decline as `invalid-repo-id`, never silently emptied). /// to decline as `invalid-repo-id`, never silently emptied).
fn normalize_repo_path(argument: &str) -> Option<String> { fn normalize_repo_path(argument: &str) -> Option<String> {
@@ -222,10 +222,10 @@ fn normalize_repo_path(argument: &str) -> Option<String> {
Some(normalized.to_owned()) Some(normalized.to_owned())
} }
/// The wire decline reason for a **featureless** row — a known channel /// The wire decline reason for a **featureless** row: a known channel
/// type this build serves nothing of (unfeatured or never-feature): /// type this build serves nothing of (unfeatured or never-feature):
/// `administratively prohibited`, the vanilla-observable policy /// `administratively prohibited`, the vanilla-observable policy
/// refusal (POC-1 §5 — channel-scoped, auth intact, no hangs). A /// refusal (POC-1 §5: channel-scoped, auth intact, no hangs). A
/// never-feature (`x11`, auth-agent) and an unfeatured feature row /// never-feature (`x11`, auth-agent) and an unfeatured feature row
/// share this class honestly: without the feature there is no /// share this class honestly: without the feature there is no
/// capability to configure, so nothing distinguishable exists. /// capability to configure, so nothing distinguishable exists.
@@ -234,14 +234,14 @@ pub(crate) fn unfeatured_open_decline() -> ChannelOpenFailure {
} }
/// The wire decline reason for a **cannot-bridge** tunnel open /// The wire decline reason for a **cannot-bridge** tunnel open
/// (`w3-tunnel-direct` — the wave-2 seam stub's reason surviving as /// (`w3-tunnel-direct`, the wave-2 seam stub's reason surviving as
/// the real decline): `connect failed`, the explicit reject shape /// the real decline): `connect failed`, the explicit reject shape
/// ADR-006 decision 1 pins — accept only after the fabric open /// ADR-006 decision 1 pins: accept only after the fabric open
/// succeeds, and a fabric-open failure maps to this explicit `SSH /// succeeds, and a fabric-open failure maps to this explicit `SSH
/// `ConnectFailed` (the channel never establishes, so the client /// `ConnectFailed` (the channel never establishes, so the client
/// observes a channel-scoped refusal, no hang). The same code covers /// observes a channel-scoped refusal, no hang). The same code covers
/// the other cannot-bridge classes of the direct rows (the fabric /// the other cannot-bridge classes of the direct rows (the fabric
/// entry unconfigured under the feature — the unset-declines posture — /// entry unconfigured under the feature (the unset-declines posture),
/// and wire arguments naming an impossible target; a vanilla client /// and wire arguments naming an impossible target; a vanilla client
/// cannot distinguish these from a dead target, and the event /// cannot distinguish these from a dead target, and the event
/// vocabulary records which). Policy refusals stay /// vocabulary records which). Policy refusals stay
@@ -252,7 +252,7 @@ pub(crate) fn connect_failed_open_decline() -> ChannelOpenFailure {
/// The wire decline reason for an open beyond the **in-crate /// The wire decline reason for an open beyond the **in-crate
/// concurrent-channel cap** (`overview.md` §Bounded-resources policy, /// concurrent-channel cap** (`overview.md` §Bounded-resources policy,
/// `w2-bounded-resources`): `administratively prohibited` — the cap is /// `w2-bounded-resources`): `administratively prohibited`; the cap is
/// the assembly's own configured policy ceiling, refused fail-closed /// the assembly's own configured policy ceiling, refused fail-closed
/// at the open (never queued, never half-open). OpenSSH's own /// at the open (never queued, never half-open). OpenSSH's own
/// `MaxSessions` ceiling answers the same code, so vanilla clients /// `MaxSessions` ceiling answers the same code, so vanilla clients
@@ -281,8 +281,8 @@ mod tests {
git(command).1 git(command).1
} }
/// - *Contract stamp:* ADR-006 decision 2 (the exec-only git parse /// - *Contract stamp:* ADR-006 decision 2 (the exec-only git parse:
/// — the vanilla wire shape `git-upload-pack '/owner/demo'`). /// the vanilla wire shape `git-upload-pack '/owner/demo'`).
#[test] #[test]
fn vanilla_quoted_upload_pack_parse_is_git() { fn vanilla_quoted_upload_pack_parse_is_git() {
let (service, repo) = git("git-upload-pack '/owner/demo'"); let (service, repo) = git("git-upload-pack '/owner/demo'");
@@ -316,16 +316,16 @@ mod tests {
assert_eq!( assert_eq!(
git_repo("git-upload-pack '/r.git.git'"), git_repo("git-upload-pack '/r.git.git'"),
"r.git", "r.git",
"one suffix only — the remainder fails validation honestly" "one suffix only; the remainder fails validation honestly"
); );
assert_eq!( assert_eq!(
git_repo("git-upload-pack '/.git'"), git_repo("git-upload-pack '/.git'"),
".git", ".git",
"the empty remainder is kept — the git row declines it, never a silent emptying" "the empty remainder is kept (the git row declines it, never a silent emptying)"
); );
} }
/// - *Contract stamp:* POC-3 §2 (service-only → declined — SSH /// - *Contract stamp:* POC-3 §2 (service-only → declined: SSH
/// exec must carry the repo; the decline-with-reason surface, /// exec must carry the repo; the decline-with-reason surface,
/// not the pipe row). /// not the pipe row).
#[test] #[test]
@@ -341,7 +341,7 @@ mod tests {
} }
/// - *Contract stamp:* POC-3 §2 (unknown services declined /// - *Contract stamp:* POC-3 §2 (unknown services declined
/// honestly — the decision that they take the decline surface, /// honestly; the decision that they take the decline surface,
/// not the pipe row). /// not the pipe row).
#[test] #[test]
fn unknown_services_parse_to_the_decline() { fn unknown_services_parse_to_the_decline() {
@@ -360,7 +360,7 @@ mod tests {
} }
/// - *Contract stamp:* mapping.md §Session-channel dispatch /// - *Contract stamp:* mapping.md §Session-channel dispatch
/// (non-git → parse-then-fail, the pipe row — the wave-2 probe's /// (non-git → parse-then-fail, the pipe row; the wave-2 probe's
/// pinned routing verdicts, preserved). /// pinned routing verdicts, preserved).
#[test] #[test]
fn non_git_commands_parse_to_the_pipe_row() { fn non_git_commands_parse_to_the_pipe_row() {
@@ -378,7 +378,7 @@ mod tests {
} }
} }
/// Unterminated empty quotes are not the vanilla path shape — the /// Unterminated empty quotes are not the vanilla path shape; the
/// probe's pipe verdict applies. A quoted path without the leading /// probe's pipe verdict applies. A quoted path without the leading
/// slash is still path-shape (the POC's loose quote trim); it rides /// slash is still path-shape (the POC's loose quote trim); it rides
/// the grammar to the git row's validation. /// the grammar to the git row's validation.
@@ -390,7 +390,7 @@ mod tests {
} }
/// Quoted-empty and slash-only arguments parse as git with an empty /// Quoted-empty and slash-only arguments parse as git with an empty
/// normalized id — the git row's validation declines them as /// normalized id; the git row's validation declines them as
/// `invalid-repo-id` (kept, never silently emptied). /// `invalid-repo-id` (kept, never silently emptied).
#[test] #[test]
fn empty_ids_parse_git_and_the_row_downstream_declines() { fn empty_ids_parse_git_and_the_row_downstream_declines() {
@@ -399,7 +399,7 @@ mod tests {
} }
/// - *Contract stamp:* mapping.md §Session-channel dispatch (deep /// - *Contract stamp:* mapping.md §Session-channel dispatch (deep
/// paths ride the grammar to the git row — validation is the /// paths ride the grammar to the git row; validation is the
/// registry-grammar gate downstream, e.g. 3 segments decline as /// registry-grammar gate downstream, e.g. 3 segments decline as
/// `invalid-repo-id`, not at the parse). /// `invalid-repo-id`, not at the parse).
#[test] #[test]
@@ -418,7 +418,7 @@ mod tests {
/// - *Contract stamp:* `client.md` §op rows (the same exec /// - *Contract stamp:* `client.md` §op rows (the same exec
/// grammar the server side parses, reused not forked); /// grammar the server side parses, reused not forked);
/// ADR-006 decision 2 — the construction arm's command parses /// ADR-006 decision 2: the construction arm's command parses
/// back through [`parse_exec`] to exactly the route it was /// back through [`parse_exec`] to exactly the route it was
/// built from, for both services and every grammar shape. /// built from, for both services and every grammar shape.
#[cfg(feature = "git")] #[cfg(feature = "git")]
@@ -451,7 +451,7 @@ mod tests {
} }
} }
/// - *Contract stamp:* the construction arm is grammar-loyal — /// - *Contract stamp:* the construction arm is grammar-loyal:
/// it is only ever reachable for the two *serving* services /// it is only ever reachable for the two *serving* services
/// (the type is the gate); the wire shape is fully /// (the type is the gate); the wire shape is fully
/// single-quote-wrapped with the leading `/`. /// single-quote-wrapped with the leading `/`.
+65 -65
View File
@@ -1,25 +1,25 @@
//! The crate's error taxonomy — fail-closed, no catch-alls (ADR-005 //! The crate's error taxonomy: fail-closed, no catch-alls (ADR-005
//! decision 4: the vault's error taxonomy is the adapter's to reshape, //! decision 4: the vault's error taxonomy is the adapter's to reshape,
//! never to collapse). //! never to collapse).
//! //!
//! Deliberately **not** error types, per the family contracts: //! Deliberately **not** error types, per the family contracts:
//! //!
//! - **Auth declines** — dispositions (`Auth::Reject`, ADR-004), //! - **Auth declines**: dispositions (`Auth::Reject`, ADR-004),
//! never errors; a decline is an authorized outcome, not a failure. //! never errors; a decline is an authorized outcome, not a failure.
//! - **Wire decline reasons** — SSH-shaped responses to the client //! - **Wire decline reasons**: SSH-shaped responses to the client
//! (the wave-2 decline core's material), never this taxonomy. //! (the wave-2 decline core's material), never this taxonomy.
//! - **Config validation** — the OQ-08/serve-config fields are //! - **Config validation**: the OQ-08/serve-config fields are
//! `Option` and decline at request time, not construction time; no //! `Option` and decline at request time, not construction time; no
//! config-error type exists unless a need is found. //! config-error type exists unless a need is found.
use thiserror::Error; use thiserror::Error;
/// Every way a vault-backed key source can fail to produce key /// Every way a vault-backed key source can fail to produce key
/// material (ADR-005 decision 4 — the failure matrix as distinct /// material (ADR-005 decision 4: the failure matrix as distinct
/// variants). All paths are fail-closed: a failed load never yields a /// variants). All paths are fail-closed: a failed load never yields a
/// dial, and a failed host-key load never yields a server. /// dial, and a failed host-key load never yields a server.
/// ///
/// The vault itself is a secret store on this path — the error /// The vault itself is a secret store on this path; the error
/// taxonomy here is the adapter's reshaping of the vault's, never a /// taxonomy here is the adapter's reshaping of the vault's, never a
/// re-export of it. Variants are deliberately closed: no catch-all, /// re-export of it. Variants are deliberately closed: no catch-all,
/// no `Other`, no `Unknown`. /// no `Other`, no `Unknown`.
@@ -28,14 +28,14 @@ pub enum KeySourceError {
/// The vault was locked when the key was requested. /// The vault was locked when the key was requested.
/// ///
/// ADR-005 semantics: the vault participates only at use time /// ADR-005 semantics: the vault participates only at use time
/// (decrypt-at-use), so a locked vault blocks exactly that — no /// (decrypt-at-use), so a locked vault blocks exactly that: no
/// key material can be decrypted, so there is no dial; on the /// key material can be decrypted, so there is no dial; on the
/// host-key path the load feeds server construction /// host-key path the load feeds server construction
/// (decrypt-at-construction), so no server is constructed /// (decrypt-at-construction), so no server is constructed
/// (POC-2 §5). Live sessions are unaffected: the decoded /// (POC-2 §5). Live sessions are unaffected: the decoded
/// `PrivateKey` is a dial's lifetime, not a vault-coupled lease. /// `PrivateKey` is a dial's lifetime, not a vault-coupled lease.
/// ///
/// Fail-closed consequence: operation refused. Never unlock — /// Fail-closed consequence: operation refused. Never unlock;
/// the vault's lifecycle is the assembly's, never the adapter's. /// the vault's lifecycle is the assembly's, never the adapter's.
#[error("vault is locked; no key material available until unlocked")] #[error("vault is locked; no key material available until unlocked")]
Locked, Locked,
@@ -43,7 +43,7 @@ pub enum KeySourceError {
/// No blob exists at the requested key location. /// No blob exists at the requested key location.
/// ///
/// ADR-005 semantics: the assembly source has no /// ADR-005 semantics: the assembly source has no
/// `EncryptedData` record for the requested key — no vault call /// `EncryptedData` record for the requested key, so no vault call
/// is needed to know (POC-2 §3). Distinct from every other /// is needed to know (POC-2 §3). Distinct from every other
/// variant on purpose: an absent key is a configuration/ /// variant on purpose: an absent key is a configuration/
/// registration gap, while a locked/decryption failure is a /// registration gap, while a locked/decryption failure is a
@@ -55,11 +55,11 @@ pub enum KeySourceError {
#[error("no key material at the requested key location")] #[error("no key material at the requested key location")]
Absent, Absent,
/// Decryption failed — wrong mnemonic or tampered ciphertext. /// Decryption failed: wrong mnemonic or tampered ciphertext.
/// ///
/// ADR-005 semantics: the AES-256-GCM open failed (POC-2 §3): /// ADR-005 semantics: the AES-256-GCM open failed (POC-2 §3):
/// the mnemonic derives an unrelated key, or the ciphertext was /// the mnemonic derives an unrelated key, or the ciphertext was
/// altered in flight — the refuse-at-crypto class. The vault /// altered in flight (the refuse-at-crypto class). The vault
/// reports this class as opaque message text, which is the /// reports this class as opaque message text, which is the
/// honest content here. /// honest content here.
/// ///
@@ -71,31 +71,31 @@ pub enum KeySourceError {
/// The record's key version is one the vault cannot resolve. /// The record's key version is one the vault cannot resolve.
/// ///
/// ADR-005 semantics: the vault declines *before* touching /// ADR-005 semantics: the vault declines *before* touching
/// crypto — an unknown/unresolvable `key_version` (reserved/legacy /// crypto: an unknown/unresolvable `key_version` (reserved/legacy
/// v1 is the TS_PBKDF2 class; a future version the vault predates /// v1 is the TS_PBKDF2 class; a future version the vault predates
/// is the other class) is refused at derivation-path resolution /// is the other class) is refused at derivation-path resolution
/// (POC-2 §3). `EncryptedData` remains opaque: the crate never /// (POC-2 §3). `EncryptedData` remains opaque: the crate never
/// interprets versions — this variant carries only the vault's /// interprets versions; this variant carries only the vault's
/// refusal message. /// refusal message.
/// ///
/// Fail-closed consequence: no dial; on the host-key path, no /// Fail-closed consequence: no dial; on the host-key path, no
/// server construction. Rotation (higher version the vault /// server construction. Rotation (higher version the vault
/// supports) is the storage-side migration — a version the /// supports) is the storage-side migration; a version the
/// vault cannot derive never becomes a partially usable key. /// vault cannot derive never becomes a partially usable key.
#[error("vault cannot resolve the record's version: {0}")] #[error("vault cannot resolve the record's version: {0}")]
UnknownKeyVersion(String), UnknownKeyVersion(String),
/// The decrypted plaintext arrived but is not decodable key /// The decrypted plaintext arrived but is not decodable key
/// material — the one-time [`decode_secret_key`](russh::keys::decode_secret_key) /// material, that is, the one-time [`decode_secret_key`](russh::keys::decode_secret_key)
/// failure. /// failure.
/// ///
/// ADR-005 semantics: the vault decrypted *something* /// ADR-005 semantics: the vault decrypted *something*
/// successfully and the plaintext is not an OpenSSH /// successfully and the plaintext is not an OpenSSH
/// private-key PEM (decision 1's inner format). Distinct from /// private-key PEM (decision 1's inner format). Distinct from
/// [`KeySourceError::Decryption`] on purpose — the /// [`KeySourceError::Decryption`] on purpose: the
/// custody boundary held, what was guarded is not a key. The /// custody boundary held, what was guarded is not a key. The
/// underlying parse error's message is deliberately *not* /// underlying parse error's message is deliberately *not*
/// carried — the machinery's text can echo plaintext-shaped /// carried, because the machinery's text can echo plaintext-shaped
/// material (the `w5-fuzz-harness` finding: the `ssh-key` PPK /// material (the `w5-fuzz-harness` finding: the `ssh-key` PPK
/// path interpolates the raw header), so the payload is /// path interpolates the raw header), so the payload is
/// crate-owned fixed text; the RC-pinned `ssh-key` error type /// crate-owned fixed text; the RC-pinned `ssh-key` error type
@@ -107,24 +107,24 @@ pub enum KeySourceError {
Decode(String), Decode(String),
} }
/// The adapter (server-direction) error surface — the failure classes /// The adapter (server-direction) error surface, the failure classes
/// the mount point can reach, deliberately closed: /// the mount point can reach, deliberately closed:
/// ///
/// - **Construction** — the [`SshAdapter::new`](crate::SshAdapter::new) /// - **Construction**: the [`SshAdapter::new`](crate::SshAdapter::new)
/// setup failed *before any session exists*: a failed host-key load /// setup failed *before any session exists*: a failed host-key load
/// (the [`KeySourceError`] variant is preserved verbatim — locked / /// (the [`KeySourceError`] variant is preserved verbatim: locked /
/// absent / decryption / unknown-version / decode each stays itself) /// absent / decryption / unknown-version / decode each stays itself)
/// or a refused `-R` listen-leg registration (`ListenRegistration`, /// or a refused `-R` listen-leg registration (`ListenRegistration`,
/// `w3-tunnel-listen` — a fabric that could mint listens the door /// `w3-tunnel-listen`; a fabric that could mint listens the door
/// cannot serve is worse than a half-configured adapter). /// cannot serve is worse than a half-configured adapter).
/// **No adapter is constructed** (ADR-005 decision 6 — never a /// **No adapter is constructed** (ADR-005 decision 6: never a
/// half-configured adapter, never a keyless server that fails /// half-configured adapter, never a keyless server that fails
/// later). /// later).
/// - **Wire** — the russh session stack's own error, mapped /// - **Wire**: the russh session stack's own error, mapped
/// transparently (`From<russh::Error>`, the wave-1 error-model's /// transparently (`From<russh::Error>`, the wave-1 error-model's
/// reserved `From` requirement of the per-connection /// reserved `From` requirement of the per-connection
/// [`russh::server::Handler`]): KEX, transport, and IO failures of /// [`russh::server::Handler`]): KEX, transport, and IO failures of
/// a mounted session. Auth declines are *not* this class — they are /// a mounted session. Auth declines are *not* this class; they are
/// dispositions (ADR-004), and wire decline reasons are the /// dispositions (ADR-004), and wire decline reasons are the
/// dispatch's SSH-shaped responses, never this taxonomy. /// dispatch's SSH-shaped responses, never this taxonomy.
/// ///
@@ -132,7 +132,7 @@ pub enum KeySourceError {
/// direction can fail is one of these classes or a decline. /// direction can fail is one of these classes or a decline.
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum AdapterError { pub enum AdapterError {
/// The host-key load at adapter construction refused — fail-closed /// The host-key load at adapter construction refused, fail-closed
/// (ADR-005 decision 6). The [`KeySourceError`] variant is the /// (ADR-005 decision 6). The [`KeySourceError`] variant is the
/// original refusal class, preserved un-collapsed. /// original refusal class, preserved un-collapsed.
#[error("ssh server construction refused: {0}")] #[error("ssh server construction refused: {0}")]
@@ -140,14 +140,14 @@ pub enum AdapterError {
/// The `-R` listen leg's door-side openable registration refused /// The `-R` listen leg's door-side openable registration refused
/// at construction (`w3-tunnel-listen`): fail-closed, never a /// at construction (`w3-tunnel-listen`): fail-closed, never a
/// half-configured adapter — a construction where the fabric could /// half-configured adapter: a construction where the fabric could
/// mint listens the door cannot serve is worse than no adapter. /// mint listens the door cannot serve is worse than no adapter.
/// The string carries the registration failure detail. /// The string carries the registration failure detail.
/// ///
/// This stays the construction-time class: the wire-time sibling /// This stays the construction-time class: the wire-time sibling
/// (`w4-router-collision`, the `ListenRouter::register` /// (`w4-router-collision`, the `ListenRouter::register`
/// token-collision refusal) is a request-time **decline /// token-collision refusal) is a request-time **decline
/// disposition** (`RequestDenied`, reason `token-collision`) — /// disposition** (`RequestDenied`, reason `token-collision`);
/// a global-request hook's `Err` (this taxonomy's only route into /// a global-request hook's `Err` (this taxonomy's only route into
/// the request surface) would kill the whole russh session, and /// the request surface) would kill the whole russh session, and
/// wire decline reasons are never this taxonomy. /// wire decline reasons are never this taxonomy.
@@ -160,53 +160,53 @@ pub enum AdapterError {
} }
/// The dial surface's failure classes (the client direction's error /// The dial surface's failure classes (the client direction's error
/// counterpart of [`AdapterError`]) — deliberately closed, every /// counterpart of [`AdapterError`]), deliberately closed, every
/// variant a no-connection outcome (ADR-005 decision 4: failures are /// variant a no-connection outcome (ADR-005 decision 4: failures are
/// fail-closed and distinct, never collapsed, never a fallback dial): /// fail-closed and distinct, never collapsed, never a fallback dial):
/// ///
/// - **KeySource** — the decrypt-at-dial load refused. The /// - **KeySource**: the decrypt-at-dial load refused. The
/// [`KeySourceError`] variant is preserved verbatim (locked / absent /// [`KeySourceError`] variant is preserved verbatim (locked / absent
/// / decryption / unknown-version / decode each stays itself — the /// / decryption / unknown-version / decode each stays itself, the
/// ADR-005 decision 4 matrix read through the dial seam). No key /// ADR-005 decision 4 matrix read through the dial seam). No key
/// material, no dial. /// material, no dial.
/// - **Address** — the dial target is not a parseable `host:port`. /// - **Address**: the dial target is not a parseable `host:port`.
/// Checked at dial time (the config carries no construction-time /// Checked at dial time (the config carries no construction-time
/// validation, the family contract); a malformed target is a /// validation, the family contract); a malformed target is a
/// refusal before any connection. /// refusal before any connection.
/// - **HostKeyRejected** — the injected host-key policy /// - **HostKeyRejected**: the injected host-key policy
/// ([`crate::HostKeyPolicy`]) declined the presented server key at /// ([`crate::HostKeyPolicy`]) declined the presented server key at
/// `check_server_key` (a `false` verdict is the decline /// `check_server_key` (a `false` verdict is the decline
/// disposition; russh surfaces it as its `UnknownKey` wire error, /// disposition; russh surfaces it as its `UnknownKey` wire error,
/// which this crate maps here because that hook is the only /// which this crate maps here because that hook is the only
/// producer). The trust decision is the policy's — its own record /// producer). The trust decision is the policy's; its own record
/// (its decision log) carries the reasons; the crate never /// (its decision log) carries the reasons; the crate never
/// second-guesses or retries. /// second-guesses or retries.
/// - **AuthRejected** — the remote end refused the publickey /// - **AuthRejected**: the remote end refused the publickey
/// authentication (the family contract is exactly one /// authentication (the family contract is exactly one
/// publickey-only attempt; there is no retry, no fallback method). /// publickey-only attempt; there is no retry, no fallback method).
/// - **Wire** — the russh session stack's own error, mapped /// - **Wire**: the russh session stack's own error, mapped
/// transparently, including the dial's connect/resolve/IO classes. /// transparently, including the dial's connect/resolve/IO classes.
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum DialError { pub enum DialError {
/// The dial key's decrypt-at-dial load refused — every /// The dial key's decrypt-at-dial load refused; every
/// [`KeySourceError`] variant is a no-dial outcome, preserved /// [`KeySourceError`] variant is a no-dial outcome, preserved
/// un-collapsed (ADR-005 decision 4). /// un-collapsed (ADR-005 decision 4).
#[error("dial key source refused: {0}")] #[error("dial key source refused: {0}")]
KeySource(#[from] KeySourceError), KeySource(#[from] KeySourceError),
/// The dial target is not a parseable `host:port` address — the /// The dial target is not a parseable `host:port` address, so the
/// wire never dialed. The string carries the address as given. /// wire never dialed. The string carries the address as given.
#[error("dial target is not a usable host:port address: {0}")] #[error("dial target is not a usable host:port address: {0}")]
Address(String), Address(String),
/// The host-key policy declined the presented server key — the /// The host-key policy declined the presented server key, so the
/// dial aborts (a disposition the dial cannot proceed on; the /// dial aborts (a disposition the dial cannot proceed on; the
/// crate default [`RejectAll`](crate::RejectAll) policy makes /// crate default [`RejectAll`](crate::RejectAll) policy makes
/// every dial refuse, by design). /// every dial refuse, by design).
#[error("host-key policy declined the presented server key")] #[error("host-key policy declined the presented server key")]
HostKeyRejected, HostKeyRejected,
/// The remote end refused the publickey authentication — the /// The remote end refused the publickey authentication; this is
/// family's one-attempt publickey-only posture (no retries, no /// family's one-attempt publickey-only posture (no retries, no
/// fallback methods, never another key). /// fallback methods, never another key).
#[error("authentication was refused by the remote end")] #[error("authentication was refused by the remote end")]
@@ -224,7 +224,7 @@ impl From<russh::Error> for DialError {
/// The transparent russh mapping with one semantic upgrade: /// The transparent russh mapping with one semantic upgrade:
/// `UnknownKey` is the wire shape of a host-key policy decline /// `UnknownKey` is the wire shape of a host-key policy decline
/// (the client's `check_server_key` hook is the only producer), /// (the client's `check_server_key` hook is the only producer),
/// so it becomes [`DialError::HostKeyRejected`] — every other /// so it becomes [`DialError::HostKeyRejected`]; every other
/// error stays the transparent [`DialError::Wire`] class. /// error stays the transparent [`DialError::Wire`] class.
fn from(error: russh::Error) -> Self { fn from(error: russh::Error) -> Self {
match error { match error {
@@ -235,22 +235,22 @@ impl From<russh::Error> for DialError {
} }
/// The family-typed mirror of the SSH `CHANNEL_OPEN` rejection /// The family-typed mirror of the SSH `CHANNEL_OPEN` rejection
/// reasons (RFC 4250 §1.5 / RFC 4254 §5.2) — the op rows translate /// reasons (RFC 4250 §1.5 / RFC 4254 §5.2); the op rows translate
/// the wire's typed-open decline into this closed vocabulary /// the wire's typed-open decline into this closed vocabulary
/// (`client.md` §op rows: the family surface carries no vanilla types). /// (`client.md` §op rows: the family surface carries no vanilla types).
/// The mapping is one-to-one with russh's /// The mapping is one-to-one with russh's
/// [`russh::ChannelOpenFailure`], whose `Other` arm mirrors its /// [`russh::ChannelOpenFailure`], whose `Other` arm mirrors its
/// `code`/`reason` pair verbatim — a decline reason the client /// `code`/`reason` pair verbatim, so a decline reason the client
/// *could not map* is never silently collapsed onto a known class. /// *could not map* is never silently collapsed onto a known class.
#[derive(Debug, Clone, Error, PartialEq, Eq)] #[derive(Debug, Clone, Error, PartialEq, Eq)]
pub enum OpenDeclineReason { pub enum OpenDeclineReason {
/// The SSH `administratively prohibited` class — a policy /// The SSH `administratively prohibited` class: a policy
/// refusal at the server (the door's own unfeatured/cap /// refusal at the server (the door's own unfeatured/cap
/// declines answer with this family's server-direction mirror). /// declines answer with this family's server-direction mirror).
#[error("the server refused the open (administratively prohibited)")] #[error("the server refused the open (administratively prohibited)")]
Prohibited, Prohibited,
/// The SSH `connect failed` class — the target could not be /// The SSH `connect failed` class: the target could not be
/// bridged (a dead `-L` target, an impossible one). /// bridged (a dead `-L` target, an impossible one).
#[error("the server rejected the open (connect failed)")] #[error("the server rejected the open (connect failed)")]
ConnectFailed, ConnectFailed,
@@ -263,7 +263,7 @@ pub enum OpenDeclineReason {
#[error("the server rejected the open (resource shortage)")] #[error("the server rejected the open (resource shortage)")]
ResourceShortage, ResourceShortage,
/// A rejection reason with no standard code — the wire's own /// A rejection reason with no standard code: the wire's own
/// `code`/`reason` pair rides (the mirror never invents a class /// `code`/`reason` pair rides (the mirror never invents a class
/// for an unmappable one). /// for an unmappable one).
#[error("the server rejected the open (code {code}: {reason})")] #[error("the server rejected the open (code {code}: {reason})")]
@@ -283,12 +283,12 @@ impl From<russh::ChannelOpenFailure> for OpenDeclineReason {
} }
} }
/// The client direction's op-row failure classes (`w4-op-rows`) — /// The client direction's op-row failure classes (`w4-op-rows`),
/// the exec/pty / tunnel / git-pack op surfaces' closed taxonomy /// the exec/pty / tunnel / git-pack op surfaces' closed taxonomy
/// (`RemoteSession` / `TunnelChannel` / `GitPackSession` — the /// (`RemoteSession` / `TunnelChannel` / `GitPackSession`, the
/// feature-gated members live in the crate root's exports), deliberately distinct from [`DialError`]: /// feature-gated members live in the crate root's exports), deliberately distinct from [`DialError`]:
/// an op runs on an *already-dialled* connection, so its failure is /// an op runs on an *already-dialled* connection, so its failure is
/// never a no-connection outcome — the connection stays usable /// never a no-connection outcome: the connection stays usable
/// ([`OpError::OpenDeclined`] / [`OpError::RequestRefused`] and /// ([`OpError::OpenDeclined`] / [`OpError::RequestRefused`] and
/// [`OpError::ChannelEnded`] are channel/op-scoped, mirroring the /// [`OpError::ChannelEnded`] are channel/op-scoped, mirroring the
/// server direction's decline shapes: channel-scoped, auth intact, /// server direction's decline shapes: channel-scoped, auth intact,
@@ -299,7 +299,7 @@ impl From<russh::ChannelOpenFailure> for OpenDeclineReason {
/// ///
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum OpError { pub enum OpError {
/// The server refused the op's **channel open** — the typed /// The server refused the op's **channel open**, the typed
/// open's decline (POC-1 §6: a rejected open resolves on the /// open's decline (POC-1 §6: a rejected open resolves on the
/// awaiting call, never a `wait()` match). The family-mirrored /// awaiting call, never a `wait()` match). The family-mirrored
/// reason rides; the connection stays usable (row-pinned). /// reason rides; the connection stays usable (row-pinned).
@@ -307,7 +307,7 @@ pub enum OpError {
OpenDeclined(#[from] OpenDeclineReason), OpenDeclined(#[from] OpenDeclineReason),
/// The server refused the op's **request** on the opened channel /// The server refused the op's **request** on the opened channel
/// — the SSH-REQUEST reply's `Failure` class, observed through /// in the SSH-REQUEST reply's `Failure` class, observed through
/// the `wait()`-drain (POC-3 §D.3: Success/Failure arrive on the /// the `wait()`-drain (POC-3 §D.3: Success/Failure arrive on the
/// channel queue, not as `Err` from the request call). The /// channel queue, not as `Err` from the request call). The
/// channel is closed behind the refusal (the row owned it). /// channel is closed behind the refusal (the row owned it).
@@ -315,26 +315,26 @@ pub enum OpError {
RequestRefused, RequestRefused,
/// The channel ended before the op's request reply arrived (the /// The channel ended before the op's request reply arrived (the
/// `wait()`-drain saw the channel's end) — the op cannot /// `wait()`-drain saw the channel's end); the op cannot
/// proceed, nothing is fabricated. /// proceed, nothing is fabricated.
#[error("the channel ended before the op's request reply arrived")] #[error("the channel ended before the op's request reply arrived")]
ChannelEnded, ChannelEnded,
/// The op's command exceeds the exec-length budget /// The op's command exceeds the exec-length budget
/// (`Limits::max_exec_command_len`) — the per-length cap's /// (`Limits::max_exec_command_len`): the per-length cap's
/// client-mirror verdict, answered **before any wire op** (bounded /// client-mirror verdict, answered **before any wire op** (bounded
/// resources; the server direction's pre-parse cap mirrored at /// resources; the server direction's pre-parse cap mirrored at
/// the client's command-construction gate). /// the client's command-construction gate).
#[error("the op's command exceeds the configured exec-command length cap")] #[error("the op's command exceeds the configured exec-command length cap")]
CommandTooLong, CommandTooLong,
/// The git row's repo id is outside the ADR-008 grammar — the /// The git row's repo id is outside the ADR-008 grammar, the
/// git row's validation gate mirrored (the server direction's /// git row's validation gate mirrored (the server direction's
/// `repo_id_validates` verdict), answered before any wire op. /// `repo_id_validates` verdict), answered before any wire op.
#[error("the repository id is outside the repo-id grammar")] #[error("the repository id is outside the repo-id grammar")]
InvalidRepoId, InvalidRepoId,
/// Unix sockets are unavailable on this platform — the /// Unix sockets are unavailable on this platform; this is the
/// `direct-streamlocal` twin's runtime-Unix gate (OQ-11: /// `direct-streamlocal` twin's runtime-Unix gate (OQ-11:
/// universal compile, clean off-Unix decline **before any wire /// universal compile, clean off-Unix decline **before any wire
/// op**), mirrored from the server direction's gate. /// op**), mirrored from the server direction's gate.
@@ -344,7 +344,7 @@ pub enum OpError {
/// The reverse-forward op's channel type has **no acceptor /// The reverse-forward op's channel type has **no acceptor
/// registered** (ADR-003 §4's client-side gate surfaced to the /// registered** (ADR-003 §4's client-side gate surfaced to the
/// *issuing* side: a reverse listen whose data leg could not serve /// *issuing* side: a reverse listen whose data leg could not serve
/// arrivals is issued never — the half-capability posture, /// arrivals is issued never; the half-capability posture,
/// answered before any wire op). Featured-but-unregistered is the /// answered before any wire op). Featured-but-unregistered is the
/// exact class: the unset-declines convention mirrored (the /// exact class: the unset-declines convention mirrored (the
/// acceptor slots are assembly-mutable, install-first is the /// acceptor slots are assembly-mutable, install-first is the
@@ -353,27 +353,27 @@ pub enum OpError {
NoAcceptor, NoAcceptor,
/// The peer refused the op's reverse-forward global request (the /// The peer refused the op's reverse-forward global request (the
/// `RequestDenied` wire decline class) — a listen denial or the /// `RequestDenied` wire decline class); a listen denial or the
/// cancel forms' unknown-listener answer (sshd parity: match by /// cancel forms' unknown-listener answer (sshd parity: match by
/// the raw wire strings). Channel-scoped honesty: the connection /// the raw wire strings). Channel-scoped honesty: the connection
/// stays usable (row-pinned), and the wire's own reason classes /// stays usable (row-pinned), and the wire's own reason classes
/// are never invented here — the closed family class mirrors the /// are never invented here; the closed family class mirrors the
/// wire shape, `RequestRefused`'s global-request counterpart. /// wire shape, `RequestRefused`'s global-request counterpart.
#[error("the peer refused the reverse-forward request")] #[error("the peer refused the reverse-forward request")]
ListenRefused, ListenRefused,
/// The op's reverse-listen admission hit the connection's /// The op's reverse-listen admission hit the connection's
/// dedicated listener ceiling (the listener counter sharing /// dedicated listener ceiling (the listener counter sharing
/// `Limits::max_concurrent_channels` — the server-side /// `Limits::max_concurrent_channels`, the server-side
/// listener-counter posture mirrored; the cap relation recorded in /// listener-counter posture mirrored; the cap relation recorded in
/// `client_accept.rs`) — answered **before any wire op**: a /// `client_accept.rs`), answered **before any wire op**: a
/// request the budget cannot track is issued never. /// request the budget cannot track is issued never.
#[error("the connection's reverse-listen count is at its ceiling")] #[error("the connection's reverse-listen count is at its ceiling")]
ListenCapExceeded, ListenCapExceeded,
/// The sftp route is the OQ-13 decline placeholder (the feature /// The sftp route is the OQ-13 decline placeholder (the feature
/// exists, the route does not until alkfs lands, on either /// exists, the route does not until alkfs lands, on either
/// direction) — answered before any wire op, never a /// direction), answered before any wire op and never a
/// half-opened subsystem channel. /// half-opened subsystem channel.
#[error("the sftp route is not served (the OQ-13 decline placeholder)")] #[error("the sftp route is not served (the OQ-13 decline placeholder)")]
SftpNotServed, SftpNotServed,
@@ -389,7 +389,7 @@ impl From<russh::Error> for OpError {
/// `russh::Error::ChannelOpenFailure` and maps to /// `russh::Error::ChannelOpenFailure` and maps to
/// [`OpError::OpenDeclined`] (family-mirrored /// [`OpError::OpenDeclined`] (family-mirrored
/// [`OpenDeclineReason`]); a denied reverse-forward *global* /// [`OpenDeclineReason`]); a denied reverse-forward *global*
/// request (`RequestDenied` — the listen/cancel forms' only wire /// request (`RequestDenied`, the listen/cancel forms' only wire
/// decline shape at this pin, the rows' only producer) maps to /// decline shape at this pin, the rows' only producer) maps to
/// [`OpError::ListenRefused`]; every other russh error stays the /// [`OpError::ListenRefused`]; every other russh error stays the
/// transparent [`OpError::Wire`]. /// transparent [`OpError::Wire`].
@@ -510,7 +510,7 @@ mod tests {
} }
/// The host-key policy's `false` verdict is russh's `UnknownKey` /// The host-key policy's `false` verdict is russh's `UnknownKey`
/// wire error — the client's `check_server_key` hook is the only /// wire error; the client's `check_server_key` hook is the only
/// producer, so the transparent mapping upgrades exactly that one /// producer, so the transparent mapping upgrades exactly that one
/// class and leaves everything else `Wire`. /// class and leaves everything else `Wire`.
/// ///
@@ -602,7 +602,7 @@ mod tests {
/// - *Contract stamp:* `client.md` §op rows (no vanilla-concept /// - *Contract stamp:* `client.md` §op rows (no vanilla-concept
/// leaks up) + POC-1 §6 (the typed open's decline resolves on /// leaks up) + POC-1 §6 (the typed open's decline resolves on
/// the awaiting call) — the wire class mirrors one-to-one into /// the awaiting call); the wire class mirrors one-to-one into
/// the family vocabulary, `Other` verbatim, nothing collapsed. /// the family vocabulary, `Other` verbatim, nothing collapsed.
#[test] #[test]
fn open_decline_mirror_preserves_every_class() { fn open_decline_mirror_preserves_every_class() {
@@ -653,7 +653,7 @@ mod tests {
} }
/// - *Contract stamp:* the reverse-forward global request's wire /// - *Contract stamp:* the reverse-forward global request's wire
/// decline class (`RequestDenied` — the listen/cancel forms' /// decline class (`RequestDenied`, the listen/cancel forms'
/// only producer shape at this pin): `ListenRefused`, never the /// only producer shape at this pin): `ListenRefused`, never the
/// transparent wire class; the typed-open mapping unchanged. /// transparent wire class; the typed-open mapping unchanged.
#[test] #[test]
@@ -662,7 +662,7 @@ mod tests {
assert!(matches!(mapped, OpError::ListenRefused), "got {mapped:?}"); assert!(matches!(mapped, OpError::ListenRefused), "got {mapped:?}");
} }
/// - *Contract stamp:* POC-1 §6 — a rejected channel open /// - *Contract stamp:* POC-1 §6, in which a rejected channel open
/// surfaces on the awaiting typed call as /// surfaces on the awaiting typed call as
/// `russh::Error::ChannelOpenFailure(..)`; the op rows map it /// `russh::Error::ChannelOpenFailure(..)`; the op rows map it
/// into [`OpError::OpenDeclined`] and every other wire error /// into [`OpError::OpenDeclined`] and every other wire error
+29 -29
View File
@@ -1,4 +1,4 @@
//! The exit-currency module — mapping.md §Exit chunk's derivation //! The exit-currency module: mapping.md §Exit chunk's derivation
//! contract (rules 1–6, normative) as code, *shared by both //! contract (rules 1–6, normative) as code, *shared by both
//! directions* (`w4-op-rows` extracted it out of `tty_map.rs`, whose //! directions* (`w4-op-rows` extracted it out of `tty_map.rs`, whose
//! server-side session rows were its first and only consumer). //! server-side session rows were its first and only consumer).
@@ -12,27 +12,27 @@
//! exactly one SSH exit report shape, **never widened**. //! exactly one SSH exit report shape, **never widened**.
//! - **The client direction** (`w4-op-rows`): the SSH exit report → //! - **The client direction** (`w4-op-rows`): the SSH exit report →
//! the family `i32` code ([`signal_code_from_name`] and the //! the family `i32` code ([`signal_code_from_name`] and the
//! direct `u32 → i32` identity) — the exact inverse arm, riding the //! direct `u32 → i32` identity), the exact inverse arm riding the
//! same table. The mirror consumes the convention as-is, including //! same table. The mirror consumes the convention as-is, including
//! its one collision (the `−1` sentinel vs a genuine SIGHUP — //! its one collision (the `−1` sentinel vs a genuine SIGHUP;
//! mapping.md rule 3's symmetric-fidelity cost). //! mapping.md rule 3's symmetric-fidelity cost).
//! //!
//! No widening in either arm: the chunk is not enriched, the wire //! No widening in either arm: the chunk is not enriched, the wire
//! report is not enriched, and the *core_dumped* bool is never //! report is not enriched, and the *core_dumped* bool is never
//! asserted (alktty discards it — nothing better is knowable). //! asserted (alktty discards it; nothing better is knowable).
//! //!
//! ## The server arm's rules (mapping.md, normative) //! ## The server arm's rules (mapping.md, normative)
//! //!
//! 1. `code ≥ 0` → `exit-status(N)`. //! 1. `code ≥ 0` → `exit-status(N)`.
//! 2. negative `−N`, N ≥ 2 → `exit-signal(name)` via the crate-local //! 2. negative `−N`, N ≥ 2 → `exit-signal(name)` via the crate-local
//! **Unix signum→name table** ([`SIGNAL_NAMES`]). Server-side //! **Unix signum→name table** ([`SIGNAL_NAMES`]). Server-side
//! work — the signal semantics belong to the serving platform //! work: the signal semantics belong to the serving platform
//! where the backend reaps. Deliberately *not* derived from //! where the backend reaps. Deliberately *not* derived from
//! alktty's `signal_from_name` inverse, which is a forward //! alktty's `signal_from_name` inverse, which is a forward
//! name→number map for the control row, not a derivation table. //! name→number map for the control row, not a derivation table.
//! `core_dumped` is **never asserted** — SSH's `exit-signal` bool //! `core_dumped` is **never asserted**; SSH's `exit-signal` bool
//! stays false on every row. //! stays false on every row.
//! 3. `−1` → `exit-status(255)`, never a signal claim — the //! 3. `−1` → `exit-status(255)`, never a signal claim: the
//! could-not-determine sentinel collides with the SIGHUP encoding //! could-not-determine sentinel collides with the SIGHUP encoding
//! on the pipe path, accepted symmetrically per the mapping: //! on the pipe path, accepted symmetrically per the mapping:
//! neither a fabricated signal for a wait failure nor a real one. //! neither a fabricated signal for a wait failure nor a real one.
@@ -41,13 +41,13 @@
//! 5. **The PTY-path fidelity limit (upstream)**: portable-pty 0.9 //! 5. **The PTY-path fidelity limit (upstream)**: portable-pty 0.9
//! reaps a signal-terminated Unix child as a plain code (the //! reaps a signal-terminated Unix child as a plain code (the
//! signal number is unrecoverable through its trait surface), so //! signal number is unrecoverable through its trait surface), so
//! signal-terminated PTY sessions arrive as `exit-status(1)` — a //! signal-terminated PTY sessions arrive as `exit-status(1)`, a
//! documented alktty/portable-pty ask; this table in practice //! documented alktty/portable-pty ask; this table in practice
//! sees the *pipe* path's negative encoding only. Faithful //! sees the *pipe* path's negative encoding only. Faithful
//! `exit-signal` there needs the alktty backend to conform to its //! `exit-signal` there needs the alktty backend to conform to its
//! own ADR's negative-code convention; alkssh's contract adapts //! own ADR's negative-code convention; alkssh's contract adapts
//! automatically. //! automatically.
//! 6. Unmapped negatives → rule 3 (`exit-status(255)`) — the mapping //! 6. Unmapped negatives → rule 3 (`exit-status(255)`); the mapping
//! never invents a name (defensive case: the convention's //! never invents a name (defensive case: the convention's
//! producers are `−1` and `−signum` only). //! producers are `−1` and `−signum` only).
//! //!
@@ -56,26 +56,26 @@
//! 1. `exit-status(N)` → the family code `N` (direct identity). //! 1. `exit-status(N)` → the family code `N` (direct identity).
//! 2. `exit-signal(name)` → the family code `−N` via the same //! 2. `exit-signal(name)` → the family code `−N` via the same
//! [`SIGNAL_NAMES`] table read backwards //! [`SIGNAL_NAMES`] table read backwards
//! ([`signal_code_from_name`]) — the mapping never invents a //! ([`signal_code_from_name`]); the mapping never invents a
//! signum. //! signum.
//! 3. A name with no table entry → `−1` (the sentinel; the same //! 3. A name with no table entry → `−1` (the sentinel; the same
//! honest could-not-determine semantic rule 3 consumes on the //! honest could-not-determine semantic rule 3 consumes on the
//! server arm — including a genuine SIGHUP, whose mirror rides //! server arm, including a genuine SIGHUP, whose mirror rides
//! `−1` by the same accepted collision). //! `−1` by the same accepted collision).
//! 4. No exit report at all ends a session with none — the //! 4. No exit report at all ends a session with none, the
//! no-exit-chunk close family's mirror ("reports nothing", //! no-exit-chunk close family's mirror ("reports nothing",
//! mapping.md): the caller observes the session's end, never a //! mapping.md): the caller observes the session's end, never a
//! fabricated code. //! fabricated code.
/// One SSH exit report shape derived from an exit chunk — /// One SSH exit report shape derived from an exit chunk;
/// [`derive_exit`]'s outcome. /// [`derive_exit`]'s outcome.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg(feature = "session")] #[cfg(feature = "session")]
pub(crate) enum ExitReport { pub(crate) enum ExitReport {
/// `exit-status(N)` — codes ≥ 0, the `−1` sentinel, and every /// `exit-status(N)`: codes ≥ 0, the `−1` sentinel, and every
/// unmapped negative (rules 1, 3, 4, 6). /// unmapped negative (rules 1, 3, 4, 6).
Status(u32), Status(u32),
/// `exit-signal(name)` — a mapped negative `−N`, N ≥ 2 (rule 2). /// `exit-signal(name)`: a mapped negative `−N` where N ≥ 2 (rule 2).
/// The name is the table's value, never an invention. /// The name is the table's value, never an invention.
Signal(&'static str), Signal(&'static str),
} }
@@ -83,11 +83,11 @@ pub(crate) enum ExitReport {
/// The crate-local Unix signum→name table (server arm rule 2 / client /// The crate-local Unix signum→name table (server arm rule 2 / client
/// arm rule 2): the 1–31 conventional Unix signals, as the exit /// arm rule 2): the 1–31 conventional Unix signals, as the exit
/// chunk's producers encode them (`−signum`) and as the wire's /// chunk's producers encode them (`−signum`) and as the wire's
/// `exit-signal` senders name them. Server-side work — the signal /// `exit-signal` senders name them. Server-side work: the signal
/// semantics belong to the serving platform where the backend reaps. /// semantics belong to the serving platform where the backend reaps.
/// Deliberately **not** derived from alktty's `signal_from_name` (a /// Deliberately **not** derived from alktty's `signal_from_name` (a
/// forward name→number map for the control row, not a derivation /// forward name→number map for the control row, not a derivation
/// table; deliberately narrower too — it exists for what a front-end /// table; deliberately narrower too, existing for what a front-end
/// sends, not for what a child can die by). /// sends, not for what a child can die by).
pub(crate) const SIGNAL_NAMES: &[(u32, &str)] = &[ pub(crate) const SIGNAL_NAMES: &[(u32, &str)] = &[
(1, "HUP"), (1, "HUP"),
@@ -124,7 +124,7 @@ pub(crate) const SIGNAL_NAMES: &[(u32, &str)] = &[
]; ];
/// The derivation contract's whole decision procedure (the §module /// The derivation contract's whole decision procedure (the §module
/// docs' rules 1–6 — normative). /// docs' rules 1–6; normative).
#[cfg(feature = "session")] #[cfg(feature = "session")]
pub(crate) fn derive_exit(code: i32) -> ExitReport { pub(crate) fn derive_exit(code: i32) -> ExitReport {
match code { match code {
@@ -143,7 +143,7 @@ pub(crate) fn derive_exit(code: i32) -> ExitReport {
/// The client arm's inverse read (§module docs, rules 1–3): the SSH /// The client arm's inverse read (§module docs, rules 1–3): the SSH
/// `exit-signal` sender's name → the family exit-code convention's /// `exit-signal` sender's name → the family exit-code convention's
/// value (`−signum`), `−1` for a name with no table entry (the /// value (`−signum`), `−1` for a name with no table entry (the
/// sentinel — the mapping never invents a signum; a genuine SIGHUP /// sentinel; the mapping never invents a signum, and a genuine SIGHUP
/// rides it by the same accepted collision). /// rides it by the same accepted collision).
pub(crate) fn signal_code_from_name(name: &str) -> i32 { pub(crate) fn signal_code_from_name(name: &str) -> i32 {
match SIGNAL_NAMES.iter().find(|(_, n)| *n == name) { match SIGNAL_NAMES.iter().find(|(_, n)| *n == name) {
@@ -155,7 +155,7 @@ pub(crate) fn signal_code_from_name(name: &str) -> i32 {
/// The table's name → russh's `Sig` vocabulary (the /// The table's name → russh's `Sig` vocabulary (the
/// `exit_signal_request` parameter). russh's known set rides its /// `exit_signal_request` parameter). russh's known set rides its
/// variant names; anything else rides `Sig::Custom` (RFC 4254 §6.10's /// variant names; anything else rides `Sig::Custom` (RFC 4254 §6.10's
/// signal-name encoding — vanilla clients parse arbitrary names). /// signal-name encoding, since vanilla clients parse arbitrary names).
#[cfg(feature = "session")] #[cfg(feature = "session")]
pub(crate) fn sig_from_name(name: &str) -> russh::Sig { pub(crate) fn sig_from_name(name: &str) -> russh::Sig {
match name { match name {
@@ -177,7 +177,7 @@ pub(crate) fn sig_from_name(name: &str) -> russh::Sig {
/// The SSH signal row's name translation: russh's `Sig` → the name /// The SSH signal row's name translation: russh's `Sig` → the name
/// string alktty's control vocabulary carries (the uppercase names; /// string alktty's control vocabulary carries (the uppercase names;
/// `Custom` carrying its own). Names flow to the backend verbatim — /// `Custom` carrying its own). Names flow to the backend verbatim,
/// no alkssh-side filter (mapping.md §the control rows; the client /// no alkssh-side filter (mapping.md §the control rows; the client
/// arm's `exit-signal` consumption reads through this before /// arm's `exit-signal` consumption reads through this before
/// [`signal_code_from_name`]). /// [`signal_code_from_name`]).
@@ -207,7 +207,7 @@ mod tests {
use russh::Sig; use russh::Sig;
/// - *Contract stamp:* mapping.md §Exit chunk rule 1 (`code ≥ 0` /// - *Contract stamp:* mapping.md §Exit chunk rule 1 (`code ≥ 0`
/// → `exit-status(N)` — the chunk carries the whole truth for /// → `exit-status(N)`; the chunk carries the whole truth for
/// clean exits). /// clean exits).
#[test] #[test]
fn non_negative_codes_map_to_exit_status() { fn non_negative_codes_map_to_exit_status() {
@@ -218,8 +218,8 @@ mod tests {
} }
/// - *Contract stamp:* mapping.md §Exit chunk rule 2 (negative /// - *Contract stamp:* mapping.md §Exit chunk rule 2 (negative
/// `−N`, N ≥ 2 → `exit-signal(name)` via the table — the POC-3 /// `−N`, N ≥ 2 → `exit-signal(name)` via the table; the POC-3
/// §D.2 live evidence: `−15` → `exit-signal("TERM")`). /// §D.2 live evidence is `−15` → `exit-signal("TERM")`).
#[test] #[test]
fn negative_signals_map_through_the_table() { fn negative_signals_map_through_the_table() {
for (signum, name) in SIGNAL_NAMES.iter().skip(1) { for (signum, name) in SIGNAL_NAMES.iter().skip(1) {
@@ -230,7 +230,7 @@ mod tests {
} }
/// - *Contract stamp:* mapping.md §Exit chunk rule 3 (`−1` → /// - *Contract stamp:* mapping.md §Exit chunk rule 3 (`−1` →
/// `exit-status(255)`, never a signal claim — the sentinel/ /// `exit-status(255)`, never a signal claim; the sentinel/
/// SIGHUP collision accepted symmetrically). /// SIGHUP collision accepted symmetrically).
#[test] #[test]
fn the_sentinel_is_not_a_signal() { fn the_sentinel_is_not_a_signal() {
@@ -238,7 +238,7 @@ mod tests {
} }
/// - *Contract stamp:* mapping.md §Exit chunk rule 6 (unmapped /// - *Contract stamp:* mapping.md §Exit chunk rule 6 (unmapped
/// negatives → rule 3 — the mapping never invents a name). /// negatives → rule 3; the mapping never invents a name).
#[test] #[test]
fn unmapped_negatives_report_255() { fn unmapped_negatives_report_255() {
for code in [i32::MIN, -33, -32, -100, -1_000_000] { for code in [i32::MIN, -33, -32, -100, -1_000_000] {
@@ -246,7 +246,7 @@ mod tests {
} }
} }
/// - *Contract stamp:* the table bound — every entry is the /// - *Contract stamp:* the table bound: every entry is the
/// conventional Unix signal set 1–31, sorted, names unique and /// conventional Unix signal set 1–31, sorted, names unique and
/// uppercase (the vocabulary the derivation table promises). /// uppercase (the vocabulary the derivation table promises).
#[test] #[test]
@@ -282,7 +282,7 @@ mod tests {
} }
} }
/// The client arm's mirror row — runnable in every build the arm /// The client arm's mirror row, runnable in every build the arm
/// compiles in (session or git; the git-only builds carry the client /// compiles in (session or git; the git-only builds carry the client
/// rows too). /// rows too).
#[cfg(test)] #[cfg(test)]
@@ -290,7 +290,7 @@ mod tests {
mod client_arm_rows { mod client_arm_rows {
use super::SIGNAL_NAMES; use super::SIGNAL_NAMES;
/// - *Contract stamp:* the client arm (§module docs' rules 1–3) — /// - *Contract stamp:* the client arm (§module docs' rules 1–3),
/// the inverse read through the same table, never widened: /// the inverse read through the same table, never widened:
/// mapped names ride `−signum`, unmapped names ride the `−1` /// mapped names ride `−signum`, unmapped names ride the `−1`
/// sentinel (the mapping never invents a signum). /// sentinel (the mapping never invents a signum).
+3 -3
View File
@@ -6,7 +6,7 @@
//! path (client) surfaces it at `check_server_key`, and assembly //! path (client) surfaces it at `check_server_key`, and assembly
//! registrations store it as a string. The string format //! registrations store it as a string. The string format
//! (`SSHFP`-style `SHA256:` prefix over the OpenSSH wire-key digest, //! (`SSHFP`-style `SHA256:` prefix over the OpenSSH wire-key digest,
//! unpadded base64) is a **contract invariant** — registration strings //! unpadded base64) is a **contract invariant**: registration strings
//! from any standard issuer (`ssh-keygen -lf`, alkcall-side //! from any standard issuer (`ssh-keygen -lf`, alkcall-side
//! computation) must compare equal to what this crate computes. //! computation) must compare equal to what this crate computes.
//! //!
@@ -17,13 +17,13 @@
use russh::keys::{HashAlg, PublicKey}; use russh::keys::{HashAlg, PublicKey};
/// Computes a public key's fingerprint — the single fingerprint path /// Computes a public key's fingerprint, the single fingerprint path
/// in the crate (ADR-004 decision 1's contract invariant). /// in the crate (ADR-004 decision 1's contract invariant).
/// ///
/// The returned string is the OpenSSH fingerprint format: `SHA256:` /// The returned string is the OpenSSH fingerprint format: `SHA256:`
/// followed by the unpadded base64 of the SHA-256 digest over the /// followed by the unpadded base64 of the SHA-256 digest over the
/// key's OpenSSH wire encoding, byte-identical to independent issuers /// key's OpenSSH wire encoding, byte-identical to independent issuers
/// (`ssh-keygen -lf`) — parity is proven, not assumed. Every /// (`ssh-keygen -lf`); parity is proven, not assumed. Every
/// fingerprint comparison in the crate and every registration value an /// fingerprint comparison in the crate and every registration value an
/// assembly stores is this string; no other formatting path exists. /// assembly stores is this string; no other formatting path exists.
pub fn fingerprint(public_key: &PublicKey) -> String { pub fn fingerprint(public_key: &PublicKey) -> String {
+30 -30
View File
@@ -1,4 +1,4 @@
//! The git mapping (`w3-git-mapping`) — the git branch of the spine //! The git mapping (`w3-git-mapping`), the git branch of the spine
//! (ADR-006 decision 2, exec-only, the alkforge primary purpose): the //! (ADR-006 decision 2, exec-only, the alkforge primary purpose): the
//! dispatch's [`ExecRoute::Git`](crate::dispatch::ExecRoute) route //! dispatch's [`ExecRoute::Git`](crate::dispatch::ExecRoute) route
//! dispatches to the published alkgit seam, //! dispatches to the published alkgit seam,
@@ -10,33 +10,33 @@
//! //!
//! ## The decisions recorded here (the task's decide-and-record set) //! ## The decisions recorded here (the task's decide-and-record set)
//! //!
//! - **Repo id — validate, never resolve.** The parse stage //! - **Repo id: validate, never resolve.** The parse stage
//! ([`crate::dispatch`]) normalizes the wire path; this stage //! ([`crate::dispatch`]) normalizes the wire path; this stage
//! validates the id against `alkgit::validate_repo_id` (1–2 //! validates the id against `alkgit::validate_repo_id` (1–2
//! segments, charset `a-z0-9-_`, ≤ 100 bytes — the ADR-008 //! segments, charset `a-z0-9-_`, ≤ 100 bytes, the ADR-008
//! repo-id grammar) and hands it through untouched to //! repo-id grammar) and hands it through untouched to
//! `serve_duplex_resolved`. **alkssh never resolves**: the //! `serve_duplex_resolved`. **alkssh never resolves**: the
//! resolution, the authorize policy and the collapsed generic //! resolution, the authorize policy and the collapsed generic
//! denial (unknown ≡ unauthorized, one wire shape, no existence //! denial (unknown ≡ unauthorized, one wire shape, no existence
//! oracle) live at the seam. An id failing the *shape* gate is //! oracle) live at the seam. An id failing the *shape* gate is
//! declined here — `invalid-repo-id`, request-level, reason-only on //! declined here as `invalid-repo-id`, request-level, reason-only on
//! the wire (the reply-vocabulary decision in [`crate::dispatch`]). //! the wire (the reply-vocabulary decision in [`crate::dispatch`]).
//! //!
//! - **Wire shapes per decline class** (the open/reply vocabulary): //! - **Wire shapes per decline class** (the open/reply vocabulary):
//! - *Request-level* declinables (`unknown-service`, `service-only` //! - *Request-level* declinables (`unknown-service` and `service-only`
//! — the parse core's; `invalid-repo-id` — this module's gate): //! from the parse core; `invalid-repo-id` from this module's gate):
//! `channel_failure`, channel left usable. The reply vocabulary is //! `channel_failure`, channel left usable. The reply vocabulary is
//! shared with parse-then-fail; the reason lives in the events //! shared with parse-then-fail; the reason lives in the events
//! only. //! only.
//! - *Serving-path* declines (the row owned the channel): //! - *Serving-path* declines (the row owned the channel):
//! seam-unconfigured (feature on, [`crate::GitSeam`] absent) and //! seam-unconfigured (feature on, [`crate::GitSeam`] absent) and
//! the identity-less guard — failure + close, from the spawned //! the identity-less guard, failure + close from the spawned
//! task, the wave-2 seam-stub shape. //! task, the wave-2 seam-stub shape.
//! - *The seam's own denials* are not alkssh declines: an unknown or //! - *The seam's own denials* are not alkssh declines: an unknown or
//! ungranted repo flows to `serve_duplex_resolved`, whose //! ungranted repo flows to `serve_duplex_resolved`, whose
//! collapsed generic denial writes the `ERR access denied: <repo>` //! collapsed generic denial writes the `ERR access denied: <repo>`
//! band over the channel **before any pack/ref byte** //! band over the channel **before any pack/ref byte**
//! (`SubstrateError::AccessDenied`) — the POC-3 §A.5 byte-order //! (`SubstrateError::AccessDenied`); the POC-3 §A.5 byte-order
//! evidence, contract-pinned by the denial rows in //! evidence, contract-pinned by the denial rows in
//! `tests/git_mapping.rs`. //! `tests/git_mapping.rs`.
//! //!
@@ -45,7 +45,7 @@
//! built from the channel's **split halves**, never //! built from the channel's **split halves**, never
//! `Channel::into_stream()`. `into_stream` wraps the whole channel //! `Channel::into_stream()`. `into_stream` wraps the whole channel
//! in russh's `ChannelCloseOnDrop`, whose `Drop` sends //! in russh's `ChannelCloseOnDrop`, whose `Drop` sends
//! server→client `CHANNEL_CLOSE` from a *spawned task* — inside the //! server→client `CHANNEL_CLOSE` from a *spawned task* inside the
//! seam call, racing [`serve_git_row`]'s explicit //! seam call, racing [`serve_git_row`]'s explicit
//! `exit_status_request` for a queue position on the same bounded //! `exit_status_request` for a queue position on the same bounded
//! `Sender<Msg>` (russh's `event_buffer_size`, default 10): a full //! `Sender<Msg>` (russh's `event_buffer_size`, default 10): a full
@@ -53,7 +53,7 @@
//! let the loop remove the channel, and silently no-op the report. //! let the loop remove the channel, and silently no-op the report.
//! The split-halves shape removes the competitor instead of racing //! The split-halves shape removes the competitor instead of racing
//! it: russh 0.65 keeps its `ChannelRx`/`ChannelTx` private, so //! it: russh 0.65 keeps its `ChannelRx`/`ChannelTx` private, so
//! [`GitChannelDuplex`] is built from the public API — the reader //! [`GitChannelDuplex`] is built from the public API: the reader
//! is the module's [`ChannelHalveReader`] (an owned read half //! is the module's [`ChannelHalveReader`] (an owned read half
//! repinning the private `ChannelRx`'s observation semantics //! repinning the private `ChannelRx`'s observation semantics
//! without the close-on-drop wrapper; its only `Drop` behavior //! without the close-on-drop wrapper; its only `Drop` behavior
@@ -62,14 +62,14 @@
//! sends EOF, exactly `into_stream`'s). Nothing the seam drops ever //! sends EOF, exactly `into_stream`'s). Nothing the seam drops ever
//! messages the wire, so the report's send is the sole //! messages the wire, so the report's send is the sole
//! close-contracting sender, and its queue position ahead of the //! close-contracting sender, and its queue position ahead of the
//! explicit close holds by construction — sequential awaited sends //! explicit close holds by construction: sequential awaited sends
//! on the one `Handle`, saturated buffer or not. The tty rows are //! on the one `Handle`, saturated buffer or not. The tty rows are
//! not in this class: their report precedes any close-contracting //! not in this class: their report precedes any close-contracting
//! drop within one task — the wave-3 gate verified. //! drop within one task (the wave-3 gate verified).
//! //!
//! - **Exit-status mapping** (POC-3 §A.5, decided): //! - **Exit-status mapping** (POC-3 §A.5, decided):
//! [`serve_git_row`] maps the serve outcome onto exactly two //! [`serve_git_row`] maps the serve outcome onto exactly two
//! codes — `Ok(())` → `exit-status(0)` (vanilla git exits 0 on //! codes: `Ok(())` → `exit-status(0)` (vanilla git exits 0 on
//! success) and every `SubstrateError` → `exit-status(1)`. `1` for //! success) and every `SubstrateError` → `exit-status(1)`. `1` for
//! `AccessDenied` is the POC's wire evidence (the russh client //! `AccessDenied` is the POC's wire evidence (the russh client
//! observed `ExitStatus{1}` after the denial band); `Io`/`Protocol` //! observed `ExitStatus{1}` after the denial band); `Io`/`Protocol`
@@ -77,32 +77,32 @@
//! machinery-misuse variants (`ResetRequired`/`WriterTaken`/ //! machinery-misuse variants (`ResetRequired`/`WriterTaken`/
//! `RawTailUnavailable`) never arise from the serving entry //! `RawTailUnavailable`) never arise from the serving entry
//! (substrate-internal misuse, never a wire shape) and are //! (substrate-internal misuse, never a wire shape) and are
//! defensively mapped to `1`. `255` is never fabricated — it is //! defensively mapped to `1`. `255` is never fabricated; it is
//! OpenSSH's could-not-start semantic, which this door never claims. //! OpenSSH's could-not-start semantic, which this door never claims.
//! The status is sent **before** close, from the spawned serving //! The status is sent **before** close, from the spawned serving
//! task via the cloned `Session::handle()` //! task via the cloned `Session::handle()`
//! ([`crate::serve`]'s mechanics; `mapping.md`'s exit_status row). //! ([`crate::serve`]'s mechanics; `mapping.md`'s exit_status row).
//! //!
//! - **Identity ride** (`w2-auth-bridge`'s parked item, discharged //! - **Identity ride** (`w2-auth-bridge`'s parked item, discharged
//! here): the resolved identity — the bridge's //! here): the resolved identity (the bridge's
//! `auth_succeeded`-state ride-along in handler state — is cloned //! `auth_succeeded`-state ride-along in handler state) is cloned
//! into the serving task and passed on //! into the serving task and passed on
//! `serve_duplex_resolved`'s identity param, so the visible-surface //! `serve_duplex_resolved`'s identity param, so the visible-surface
//! invariant is observable through the git row: alkgit's //! invariant is observable through the git row: alkgit's
//! `authorize` evaluation is the live witness (a granted-private //! `authorize` evaluation is the live witness (a granted-private
//! repo serves, an ungranted one denies — the harness rows pin //! repo serves, an ungranted one denies; the harness rows pin
//! both). A handler state without an identity is unreachable (russh //! both). A handler state without an identity is unreachable (russh
//! authorizes before channels exist) and declines serving-path //! authorizes before channels exist) and declines serving-path
//! (`no-identity`) — never anonymous serving. //! (`no-identity`), never anonymous serving.
//! //!
//! - **Process-mode dialect**: alkgit speaks first (fetch: the V2 //! - **Process-mode dialect**: alkgit speaks first (fetch: the V2
//! advertisement, no `# service=` prefix; push: the V0 ref //! advertisement, no `# service=` prefix; push: the V0 ref
//! advertisement) — nothing alkssh-side; the byte-level rows in //! advertisement), nothing alkssh-side; the byte-level rows in
//! `tests/git_mapping.rs` verify the dialect holds over the channel //! `tests/git_mapping.rs` verify the dialect holds over the channel
//! duplex. //! duplex.
//! //!
//! The serve outcome's event shape: the serving-path lifecycle only //! The serve outcome's event shape: the serving-path lifecycle only
//! (started / ended — the vocabulary's `warn`-failed when the wire //! (started / ended, with the vocabulary's `warn`-failed when the wire
//! reporting breaks). A serve `Err` is *not* a decline event: the //! reporting breaks). A serve `Err` is *not* a decline event: the
//! substrate already mapped its own error onto the wire (bands, the //! substrate already mapped its own error onto the wire (bands, the
//! denial), and the client's observation is the exit-status above. //! denial), and the client's observation is the exit-status above.
@@ -123,7 +123,7 @@ use crate::dispatch::GitServiceRoute;
use crate::observe; use crate::observe;
/// The git row's validation gate: the id's shape against /// The git row's validation gate: the id's shape against
/// `alkgit::validate_repo_id` — the ADR-008 repo-id grammar for /// `alkgit::validate_repo_id`, the ADR-008 repo-id grammar for
/// *rejection* only (the mapping-free posture; the id is never /// *rejection* only (the mapping-free posture; the id is never
/// decomposed, joined, or normalized into paths beyond /// decomposed, joined, or normalized into paths beyond
/// [`crate::dispatch`]'s quote/slash/`.git` stage). /// [`crate::dispatch`]'s quote/slash/`.git` stage).
@@ -150,13 +150,13 @@ fn exit_code(outcome: &Result<(), SubstrateError>) -> u32 {
} }
} }
/// The seam's duplex over the **split** channel halves — the /// The seam's duplex over the **split** channel halves, the
/// status-before-close shape's carrier (the module doc's /// status-before-close shape's carrier (the module doc's
/// §Status-before-close decision). russh 0.65 keeps its `ChannelRx`/ /// §Status-before-close decision). russh 0.65 keeps its `ChannelRx`/
/// `ChannelTx` types private, so both halves are built from public /// `ChannelTx` types private, so both halves are built from public
/// API: the reader is a [`ChannelHalveReader`] (an owned close-free /// API: the reader is a [`ChannelHalveReader`] (an owned close-free
/// read half), the writer the write half's `ChannelTx` via /// read half), the writer the write half's `ChannelTx` via
/// `make_writer_ext(None)` — its `poll_shutdown` sends EOF, exactly /// `make_writer_ext(None)`; its `poll_shutdown` sends EOF, exactly
/// `into_stream`'s semantics, its `Drop` messages nothing (the /// `into_stream`'s semantics, its `Drop` messages nothing (the
/// `ChannelTx` `Drop` is a writer-notification only). Nothing the /// `ChannelTx` `Drop` is a writer-notification only). Nothing the
/// seam drops ever messages the wire, so the report's send is the /// seam drops ever messages the wire, so the report's send is the
@@ -195,20 +195,20 @@ impl AsyncWrite for GitChannelDuplex {
} }
} }
/// The owned close-free read half — the seam-side reader. Semantics /// The owned close-free read half, the seam-side reader. Semantics
/// repinned from russh's private `ChannelRx<S>` (the `into_stream` /// repinned from russh's private `ChannelRx<S>` (the `into_stream`
/// shape's reader; identical observable behavior without the /// shape's reader; identical observable behavior without the
/// `ChannelCloseOnDrop` wrapper): `Data` fills the buffer (partial /// `ChannelCloseOnDrop` wrapper): `Data` fills the buffer (partial
/// reads buffered), `ExtendedData` with no sideband and the other /// reads buffered), `ExtendedData` with no sideband and the other
/// non-data messages are swallowed and re-read (the `_` arm), /// non-data messages are swallowed and re-read (the `_` arm),
/// `Eof` ends the stream, and the peer's `Close` leaves the reader /// `Eof` ends the stream, and the peer's `Close` leaves the reader
/// to the channel's sender-death EOF (`wait()` → `None` — the /// to the channel's sender-death EOF (`wait()` → `None`, the
/// channel's internal sender drops at the loop's close processing). /// channel's internal sender dropping at the loop's close processing).
/// ///
/// The `wait()` access is future-owned: each read round-trips the /// The `wait()` access is future-owned: each read round-trips the
/// half through a boxed [`Future`] that parks it inside russh's /// half through a boxed [`Future`] that parks it inside russh's
/// `ChannelReadHalf::wait` and hands it back with the observed /// `ChannelReadHalf::wait` and hands it back with the observed
/// message on completion — the reader is fully owned (usable under /// message on completion; the reader is fully owned (usable under
/// the seam's `'static` `T` bound) across every park, buffer and /// the seam's `'static` `T` bound) across every park, buffer and
/// drop state; no `Drop` path ever messages the wire. /// drop state; no `Drop` path ever messages the wire.
struct ChannelHalveReader { struct ChannelHalveReader {
@@ -296,15 +296,15 @@ fn git_channel_duplex(channel: Channel<Msg>) -> GitChannelDuplex {
} }
/// The spawned git serving path: dispatch to the seam over the /// The spawned git serving path: dispatch to the seam over the
/// [`git_channel_duplex`] (the split halves — the seam contracts /// [`git_channel_duplex`] (the split halves; the seam contracts
/// nothing on its drops), then the exit-status-then-close report /// nothing on its drops), then the exit-status-then-close report
/// through the cloned handle — the report's send is the sole /// through the cloned handle; the report's send is the sole
/// close-contracting sender and each send awaits before the next is /// close-contracting sender and each send awaits before the next is
/// issued, so the status-before-close order holds by construction /// issued, so the status-before-close order holds by construction
/// under any schedule. The channel was taken from the handler's store /// under any schedule. The channel was taken from the handler's store
/// by the dispatch (the store-and-take handoff); the spawned task owns /// by the dispatch (the store-and-take handoff); the spawned task owns
/// the halves and their report surface. A failing report send means /// the halves and their report surface. A failing report send means
/// the session is gone — the lifecycle's `warn`-failed records it (the /// the session is gone; the lifecycle's `warn`-failed records it (the
/// one warn of the vocabulary). /// one warn of the vocabulary).
pub(crate) async fn serve_git_row( pub(crate) async fn serve_git_row(
handle: Handle, handle: Handle,
+147 -147
View File
@@ -1,4 +1,4 @@
//! The per-connection server handler — the ADR-004 dispositional auth //! The per-connection server handler: the ADR-004 dispositional auth
//! bridge, plus the exec-grammar dispatch and the fail-closed decline //! bridge, plus the exec-grammar dispatch and the fail-closed decline
//! core (`w2-dispatch-decline-core`; `w2-auth-bridge` filled the auth //! core (`w2-dispatch-decline-core`; `w2-auth-bridge` filled the auth
//! hooks; `w3-git-mapping` replaced the skeleton probe with the full //! hooks; `w3-git-mapping` replaced the skeleton probe with the full
@@ -9,30 +9,30 @@
//! //!
//! russh performs all cryptographic work: [`Handler::auth_publickey`] //! russh performs all cryptographic work: [`Handler::auth_publickey`]
//! fires *after* signature and ownership verification, so this handler //! fires *after* signature and ownership verification, so this handler
//! never sees or verifies a signature — its surface is dispositions //! never sees or verifies a signature; its surface is dispositions
//! only. The single resolution path: ed25519-only gate → the crate's //! only. The single resolution path: ed25519-only gate → the crate's
//! one fingerprint path ([`crate::fingerprint::fingerprint`]) → the //! one fingerprint path ([`crate::fingerprint::fingerprint`]) → the
//! injected `IdentityProvider::resolve_from_fingerprint` → `Some` //! injected `IdentityProvider::resolve_from_fingerprint` → `Some`
//! accepts with the resolved identity stored in per-connection handler //! accepts with the resolved identity stored in per-connection handler
//! state (it rides the session for the wave-3 seam propagation — //! state (it rides the session for the wave-3 seam propagation,
//! [`Handler::auth_succeeded`] is the ride-along hook); `None` rejects //! [`Handler::auth_succeeded`] is the ride-along hook); `None` rejects
//! fail-closed — the resolved-identity-`None` path never creates a //! fail-closed; the resolved-identity-`None` path never creates a
//! weakly authorized session. //! weakly authorized session.
//! //!
//! Every other method is an **explicit** reject — [`Handler::auth_none`], //! Every other method is an **explicit** reject: [`Handler::auth_none`],
//! [`Handler::auth_password`] and [`Handler::auth_keyboard_interactive`] //! [`Handler::auth_password`] and [`Handler::auth_keyboard_interactive`]
//! each implemented, never inherited — so the posture survives a pin //! each implemented, never inherited, so the posture survives a pin
//! bump that could silently change russh's defaults (ADR-004 decision //! bump that could silently change russh's defaults (ADR-004 decision
//! 3). The non-ed25519 algorithm decline is explicit-over-inherited too //! 3). The non-ed25519 algorithm decline is explicit-over-inherited too
//! (ADR-002 decision 4). [`Handler::auth_publickey_offered`] //! (ADR-002 decision 4). [`Handler::auth_publickey_offered`]
//! accepts-and-delegates explicitly (the probe hook consumes no attempt //! accepts-and-delegates explicitly (the probe hook consumes no attempt,
//! — POC-1 verified; the delegation keeps `max_auth_attempts` counting //! POC-1 verified; the delegation keeps `max_auth_attempts` counting
//! honest). //! honest).
//! //!
//! The username carries no semantics (OQ-03 resolved): resolution is //! The username carries no semantics (OQ-03 resolved): resolution is
//! fingerprint-only — the bridge core takes no username parameter at //! fingerprint-only; the bridge core takes no username parameter at
//! all, structurally refusing any identity input from the user string. //! all, structurally refusing any identity input from the user string.
//! Auth declines are *dispositions* (`Auth::Reject`), not errors — the //! Auth declines are *dispositions* (`Auth::Reject`), not errors; the
//! error model stays key-source-only. //! error model stays key-source-only.
//! //!
//! ## The parse-only dispatch + the fail-closed decline core //! ## The parse-only dispatch + the fail-closed decline core
@@ -41,23 +41,23 @@
//! exec-parse skeleton probe live in [`crate::dispatch`].) Every //! exec-parse skeleton probe live in [`crate::dispatch`].) Every
//! `channel_open_*` hook gates per the feature graph: `session` //! `channel_open_*` hook gates per the feature graph: `session`
//! channels open only where a session-serving feature exists (`session` //! channels open only where a session-serving feature exists (`session`
//! ∨ `git` — the parse path lives inside session channels); //! ∨ `git`, since the parse path lives inside session channels);
//! `direct-tcpip` / `forwarded-tcpip` / `direct-streamlocal` gate on //! `direct-tcpip` / `forwarded-tcpip` / `direct-streamlocal` gate on
//! the `tunnel` feature; **`x11` always declines** — never //! the `tunnel` feature; **`x11` always declines**, never
//! feature-gated (ADR-003 §5). Unfeatured and never-feature rows //! feature-gated (ADR-003 §5). Unfeatured and never-feature rows
//! decline **at the open** with a clean wire reason //! decline **at the open** with a clean wire reason
//! ([`crate::dispatch::unfeatured_open_decline`]) — never a //! ([`crate::dispatch::unfeatured_open_decline`]), never a
//! half-open channel. The `direct-tcpip` / `direct-streamlocal` //! half-open channel. The `direct-tcpip` / `direct-streamlocal`
//! featured rows are the route-1 bridge (`w3-tunnel-direct`, //! featured rows are the route-1 bridge (`w3-tunnel-direct`,
//! [`crate::tunnel_map`]): the pre-admit decline classes (the unset //! [`crate::tunnel_map`]): the pre-admit decline classes (the unset
//! fabric handle under the feature — the unset-declines posture; the //! fabric handle under the feature, the unset-declines posture; the
//! unreachable-in-practice `no-identity`; an impossible wire target), //! unreachable-in-practice `no-identity`; an impossible wire target),
//! the cap admission at the hook, then the spawned bridge — accept //! the cap admission at the hook, then the spawned bridge; accept
//! only after the fabric open succeeds, `ConnectFailed` on failure //! only after the fabric open succeeds, `ConnectFailed` on failure
//! ([`crate::dispatch::connect_failed_open_decline`], the wave-2 //! ([`crate::dispatch::connect_failed_open_decline`], the wave-2
//! seam stub's reason surviving as the real decline). //! seam stub's reason surviving as the real decline).
//! An **inbound** `forwarded-tcpip` open — arriving *from* the vanilla //! An **inbound** `forwarded-tcpip` open, arriving *from* the vanilla
//! client — declines with the same explicit `ConnectFailed` as the //! client, declines with the same explicit `ConnectFailed` as the
//! unvanilla-arrival class (`w3-tunnel-listen`: the real `-R` data //! unvanilla-arrival class (`w3-tunnel-listen`: the real `-R` data
//! leg is the door's own server-initiated opens, driven by the //! leg is the door's own server-initiated opens, driven by the
//! [`crate::tunnel_map`] arrival pump; a vanilla client never //! [`crate::tunnel_map`] arrival pump; a vanilla client never
@@ -65,47 +65,47 @@
//! The bare //! The bare
//! `auth-agent@openssh.com` channel *type* //! `auth-agent@openssh.com` channel *type*
//! (`forwarded-streamlocal@openssh.com` likewise) has no russh handler //! (`forwarded-streamlocal@openssh.com` likewise) has no russh handler
//! hook at this pin — russh rejects those opens inline //! hook at this pin; russh rejects those opens inline
//! (administratively prohibited), which is the decline the never- //! (administratively prohibited), which is the decline the never-
//! feature contract needs, but it is an inherited posture: a pin bump //! feature contract needs, but it is an inherited posture: a pin bump
//! that changed it would surface here as an accepted-then-dropped //! that changed it would surface here as an accepted-then-dropped
//! channel (third-party-upstream note; file upstream first). //! channel (third-party-upstream note; file upstream first).
//! //!
//! The session rows are **parse-only**: `exec_request` parses first //! The session rows are **parse-only**: `exec_request` parses first
//! (the full grammar, [`crate::dispatch::parse_exec`] — a git route to //! (the full grammar, [`crate::dispatch::parse_exec`], a git route to
//! the git row, a declined-with-reason grammar class to its honest //! the git row, a declined-with-reason grammar class to its honest
//! decline, anything else to the session pipe row); `pty_request` / //! decline, anything else to the session pipe row); `pty_request` /
//! `shell_request` route to the session rows. Nothing is served before //! `shell_request` route to the session rows. Nothing is served before
//! a request exists — no request, no serving, so a request-less //! a request exists: no request, no serving, so a request-less
//! session channel declines as unfeatured by construction. //! session channel declines as unfeatured by construction.
//! **Decline first, alloc never**: every decline here precedes any //! **Decline first, alloc never**: every decline here precedes any
//! channel/resource allocation (a leaked channel alloc is a //! channel/resource allocation (a leaked channel alloc is a
//! capability leak — the POC-3 stage-A rule); the per-length //! capability leak, the POC-3 stage-A rule); the per-length
//! `max_exec_command_len` cap is enforced before the parse itself. //! `max_exec_command_len` cap is enforced before the parse itself.
//! //!
//! The store-and-take channel handoff (`w3-git-mapping`) //! The store-and-take channel handoff (`w3-git-mapping`)
//! //!
//! A confirmed `session` open **stores** its [`Channel`] in //! A confirmed `session` open **stores** its [`Channel`] in
//! per-connection handler state ([`Self::take_stored_channel`]'s //! per-connection handler state ([`Self::take_stored_channel`]'s
//! supplier: `channel_open_session` accepts, then inserts — the //! supplier: `channel_open_session` accepts, then inserts; the
//! POC-3 §A.4 accepted-but-held shape); the git row's `exec_request` //! POC-3 §A.4 accepted-but-held shape); the git row's `exec_request`
//! **takes** it (`channels.remove(&id)` → `into_stream()` in the //! **takes** it (`channels.remove(&id)` → `into_stream()` in the
//! spawned serving task) and the session's packet loop keeps owning //! spawned serving task) and the session's packet loop keeps owning
//! the store: `channel_close` removes whatever it held (a channel //! the store: `channel_close` removes whatever it held (a channel
//! that served or declined without an exec frees there), and every //! that served or declined without an exec frees there), and every
//! mutation happens on the loop's task — single-writer, no lock. A //! mutation happens on the loop's task: single-writer, no lock. A
//! git exec on a channel whose stored handle is already gone //! git exec on a channel whose stored handle is already gone
//! (re-served, or never held — unvanilla) declines serving-path. //! (re-served, or never held, which is unvanilla) declines serving-path.
//! //!
//! ## The session rows' real serving paths (`w3-tty-session`) //! ## The session rows' real serving paths (`w3-tty-session`)
//! //!
//! The pty/shell/pipe rows replaced their wave-3 skeleton stubs with //! The pty/shell/pipe rows replaced their wave-3 skeleton stubs with
//! the alktty translation bridge ([`crate::tty_map`]): the pty row //! the alktty translation bridge ([`crate::tty_map`]): the pty row
//! stores the negotiated `TerminalParamsWire` ([`Self::ptys`]'s //! stores the negotiated `TerminalParamsWire` ([`Self::ptys`]'s
//! supplier — taken by the later shell/exec request on the same //! supplier, taken by the later shell/exec request on the same
//! channel, `ssh -t host "cmd"`'s pty-then-exec); the shell and pipe //! channel, `ssh -t host "cmd"`'s pty-then-exec); the shell and pipe
//! rows take the stored channel the same way the git row does and //! rows take the stored channel the same way the git row does and
//! spawn `serve_tty_row` over the configured seam — locally-built //! spawn `serve_tty_row` over the configured seam, with locally-built
//! negotiation, unset-fields-decline (OQ-08: `shell_argv` unset → //! negotiation, unset-fields-decline (OQ-08: `shell_argv` unset →
//! the shell row declines, `exec_wrapper_argv` unset → the pipe row //! the shell row declines, `exec_wrapper_argv` unset → the pipe row
//! declines, `session_seam` unset → both rows and the pty row //! declines, `session_seam` unset → both rows and the pty row
@@ -118,15 +118,15 @@
//! ## The control rows' per-channel surface (`w3-tty-session`) //! ## The control rows' per-channel surface (`w3-tty-session`)
//! //!
//! `window_change_request` and `signal` arrive on the packet loop //! `window_change_request` and `signal` arrive on the packet loop
//! **after** the serving task owns the wire — the spawned bridge is //! **after** the serving task owns the wire, and the spawned bridge is
//! reached through [`Self::bridges`]' per-channel surface //! reached through [`Self::bridges`]' per-channel surface
//! ([`crate::tty_map::BridgeSurface`]: the bounded control mpsc's //! ([`crate::tty_map::BridgeSurface`]: the bounded control mpsc's
//! `try_send` — never awaited on the packet loop — plus the //! `try_send` (never awaited on the packet loop), plus the
//! per-channel cancel). A resize for a channel whose pty is stored //! per-channel cancel). A resize for a channel whose pty is stored
//! but not yet serving updates the stored params (the final size //! but not yet serving updates the stored params (the final size
//! wins); signal names are accepted verbatim (the backend owns the //! wins); signal names are accepted verbatim (the backend owns the
//! semantics). Unfeatured builds emit the request-level decline //! semantics). Unfeatured builds emit the request-level decline
//! event with no reply — the control rows are fire-and-forget, no //! event with no reply; the control rows are fire-and-forget, no
//! SSH reply exists to send. [`Self::channel_close`] releases the //! SSH reply exists to send. [`Self::channel_close`] releases the
//! surface (firing the cancel: the alktty kill-on-drop contract is //! surface (firing the cancel: the alktty kill-on-drop contract is
//! the cancelled session's cleanup) along with the channel and pty //! the cancelled session's cleanup) along with the channel and pty
@@ -137,45 +137,45 @@
//! ## The spawn-and-return structural constraint (ADR-006 decision 4) //! ## The spawn-and-return structural constraint (ADR-006 decision 4)
//! //!
//! russh at this pin runs handler callbacks **inline inside the //! russh at this pin runs handler callbacks **inline inside the
//! session's packet loop** — a blocking handler suspends all packet //! session's packet loop**: a blocking handler suspends all packet
//! routing and deadlocks against the peer's follow-up bytes (POC-3 //! routing and deadlocks against the peer's follow-up bytes (POC-3
//! proved by construction with receive-pack). Every serving path is //! proved by construction with receive-pack). Every serving path is
//! therefore spawned through [`crate::serve::spawn_serving_path`] and //! therefore spawned through [`crate::serve::spawn_serving_path`] and
//! the handler returns fast: the serving rows (the git row's alkgit //! the handler returns fast: the serving rows (the git row's alkgit
//! dispatch, the session rows' config-driven declines and //! dispatch, the session rows' config-driven declines and
//! [`crate::tty_map`]'s bridge) all clone `session.handle()`, spawn //! [`crate::tty_map`]'s bridge) all clone `session.handle()`, spawn
//! through the clone, and are gone from the hook's stack — no hook //! through the clone, and are gone from the hook's stack; no hook
//! awaits a serving path inline. The decline keeps its wire shape //! awaits a serving path inline. The decline keeps its wire shape
//! (failure then close), riding the spawn. Exit/status reporting from //! (failure then close), riding the spawn. Exit/status reporting from
//! spawned tasks rides the same cloned handle //! spawned tasks rides the same cloned handle
//! ([`crate::serve::report_exit_status_then_close`] — the mechanics; //! ([`crate::serve::report_exit_status_then_close`], the mechanics;
//! the tty rows' derivation rules live in [`crate::tty_map`]), pinned //! the tty rows' derivation rules live in [`crate::tty_map`]), pinned
//! end-to-end by the mechanics rows in `tests/spawn_return.rs` and the //! end-to-end by the mechanics rows in `tests/spawn_return.rs` and the
//! mapping rows in `tests/git_mapping.rs` / `tests/tty_mapping.rs`. //! mapping rows in `tests/git_mapping.rs` / `tests/tty_mapping.rs`.
//! //!
//! The open-level declines (unfeatured/never-feature rows, the //! The open-level declines (unfeatured/never-feature rows, the
//! direct rows' pre-admit classes) stay inline by class: they answer //! direct rows' pre-admit classes) stay inline by class: they answer
//! the open *before* any channel is owned — nothing is served yet, //! the open *before* any channel is owned: nothing is served yet,
//! so nothing spawns. The route-1 bridge (`w3-tunnel-direct`) moved //! so nothing spawns. The route-1 bridge (`w3-tunnel-direct`) moved
//! the direct rows' accept past the fabric open: the accept and the //! the direct rows' accept past the fabric open: the accept and the
//! pump ride the spawned task, per the same constraint. The `-R` //! pump ride the spawned task, per the same constraint. The `-R`
//! listen rows' family-ward dial is the one **inline await** on the //! listen rows' family-ward dial is the one **inline await** on the
//! loop (`w3-tunnel-listen`'s recorded decision: the global request's //! loop (`w3-tunnel-listen`'s recorded decision: the global request's
//! reply + the `&mut u32` bound-port out-param are the hook's own — //! reply + the `&mut u32` bound-port out-param are the hook's own,
//! no spawned-task answer exists at this pin — and the fabric's //! no spawned-task answer exists at this pin, and the fabric's
//! answer rides the fabric leg's connections, not this session's //! answer rides the fabric leg's connections, not this session's
//! routing; bounded by `LISTEN_DIAL_TIMEOUT`), while everything under //! routing; bounded by `LISTEN_DIAL_TIMEOUT`), while everything under
//! the reply — the arrivals, the pumps — stays spawned. //! the reply (the arrivals, the pumps) stays spawned.
//! //!
//! Request-level declines: `subsystem_request` declines every name — //! Request-level declines: `subsystem_request` declines every name:
//! `sftp` is the OQ-13 placeholder (the feature exists, the route //! `sftp` is the OQ-13 placeholder (the feature exists, the route
//! does not until alkfs lands); git service names carry only a name, //! does not until alkfs lands); git service names carry only a name,
//! never a repository id, so git can never dispatch as a subsystem //! never a repository id, so git can never dispatch as a subsystem
//! (ADR-006 decision 2); everything else is unimplemented. `env_request` //! (ADR-006 decision 2); everything else is unimplemented. `env_request`
//! declines unconditionally (OQ-09 — no allowlist ships: wire env is //! declines unconditionally (OQ-09; no allowlist ships: wire env is
//! not family config). `x11_request` and `agent_request` decline //! not family config). `x11_request` and `agent_request` decline
//! (ADR-003 §5). The global listen requests (`tcpip_forward` //! (ADR-003 §5). The global listen requests (`tcpip_forward`
//! / `streamlocal_forward` + their cancel forms) — the `-R` rows //! / `streamlocal_forward` + their cancel forms), the `-R` rows
//! (`w3-tunnel-listen`): the real flow under `tunnel` with the listen //! (`w3-tunnel-listen`): the real flow under `tunnel` with the listen
//! seam configured (the family-ward `TunnelListener::open` dial, the //! seam configured (the family-ward `TunnelListener::open` dial, the
//! arrivals' arrival pump through the shared router); every decline //! arrivals' arrival pump through the shared router); every decline
@@ -186,7 +186,7 @@
//! Two decline classes, chosen for vanilla-client observability (POC-1 //! Two decline classes, chosen for vanilla-client observability (POC-1
//! §5: channel-scoped decline, auth intact, no hangs): //! §5: channel-scoped decline, auth intact, no hangs):
//! //!
//! - **Open-level declines** — channel-open failure codes (`x11` //! - **Open-level declines** (channel-open failure codes: `x11`
//! never-feature, unfeatured feature rows, the tunnel rows' //! never-feature, unfeatured feature rows, the tunnel rows'
//! identity-less guard and beyond-cap refusals → administratively //! identity-less guard and beyond-cap refusals → administratively
//! prohibited; the direct rows' cannot-bridge classes (the fabric //! prohibited; the direct rows' cannot-bridge classes (the fabric
@@ -196,54 +196,54 @@
//! connect failed). These //! connect failed). These
//! happen before the channel exists, so the SSH open-failure code is //! happen before the channel exists, so the SSH open-failure code is
//! the honest, richest reason surface. //! the honest, richest reason surface.
//! - **Request-level declines** — [`Session::channel_failure`]: the //! - **Request-level declines** ([`Session::channel_failure`]): the
//! SSH request-reply vocabulary has no failure text, so a request //! SSH request-reply vocabulary has no failure text, so a request
//! decline's "reason" is the routing decision itself (documented //! decline's "reason" is the routing decision itself (documented
//! above, in [`crate::dispatch`]'s grammar, and per feature build in //! above, in [`crate::dispatch`]'s grammar, and per feature build in
//! `mapping.md`'s decline matrix). A *capability* decline answers //! `mapping.md`'s decline matrix). A *capability* decline answers
//! failure and leaves the channel usable (the POC-1 §5 //! failure and leaves the channel usable (the POC-1 §5
//! session-survives-declines posture) — including the git grammar's //! session-survives-declines posture), including the git grammar's
//! declinables (`unknown-service` / `service-only` / //! declinables (`unknown-service` / `service-only` /
//! `invalid-repo-id`, the parsed reply shapes, `w3-git-mapping`); //! `invalid-repo-id`, the parsed reply shapes, `w3-git-mapping`);
//! a *serving-path* decline (a routable row that owns the request's //! a *serving-path* decline (a routable row that owns the request's
//! whole outcome: the seam-unconfigured git row, the session rows' //! whole outcome: the seam-unconfigured git row, the session rows'
//! unset-field and unset-seam declines — `w3-tty-session`'s OQ-08 //! unset-field and unset-seam declines, `w3-tty-session`'s OQ-08
//! shapes — and the identity-less guard) answers failure and closes //! shapes, and the identity-less guard) answers failure and closes
//! the channel — the vanilla-clean shape for a row whose serving //! the channel, the vanilla-clean shape for a row whose serving
//! path cannot exist. The serving-path decline rides the spawned //! path cannot exist. The serving-path decline rides the spawned
//! serving task (ADR-006 decision 4, §The spawn-and-return //! serving task (ADR-006 decision 4, §The spawn-and-return
//! structural constraint above); the wire shape is unchanged. The //! structural constraint above); the wire shape is unchanged. The
//! `channel-not-held` guard is the inline request-level shape (the //! `channel-not-held` guard is the inline request-level shape (the
//! channel that could not be held stays usable). The real serve's //! channel that could not be held stays usable). The real serve's
//! own outcomes are not declines: the substrate maps them (denial //! own outcomes are not declines: the substrate maps them (denial
//! band before any protocol byte; exit-status then close — the //! band before any protocol byte; exit-status then close (the
//! `exit_code` mapping decision recorded in [`crate::git_map`]; //! `exit_code` mapping decision recorded in [`crate::git_map`]);
//! the tty bridge's negotiation-error and exit-chunk decisions in //! the tty bridge's negotiation-error and exit-chunk decisions in
//! [`crate::tty_map`]). //! [`crate::tty_map`]).
//! //!
//! ## The event vocabulary on every decline (`w3-decline-tracing`) //! ## The event vocabulary on every decline (`w3-decline-tracing`)
//! //!
//! Every decline site here emits its event through //! Every decline site here emits its event through
//! [`crate::observe`] — the wave-2 gate's booked finding: the //! [`crate::observe`], the wave-2 gate's booked finding: the
//! deciding context (row, feature build, channel id, decline class, //! deciding context (row, feature build, channel id, decline class,
//! reason) rides the tracing event, not just the routing decision //! reason) rides the tracing event, not just the routing decision
//! documented per build. The vocabulary and its safety posture (no //! documented per build. The vocabulary and its safety posture (no
//! secrets, no username, no fingerprint — structurally) live in that //! secrets, no username, no fingerprint; enforced structurally) live in that
//! module; the serving-path lifecycle (started / ended / failure) //! module; the serving-path lifecycle (started / ended / failure)
//! hangs off the spawned stub body and is what the wave-3 mappings' //! hangs off the spawned stub body and is what the wave-3 mappings'
//! real serving paths inherit. //! real serving paths inherit.
//! //!
//! ## Bounded resources at the handler surface (`w2-bounded-resources`) //! ## Bounded resources at the handler surface (`w2-bounded-resources`)
//! //!
//! (AGENTS.md convention 9; `overview.md` §Bounded-resources policy — //! (AGENTS.md convention 9; `overview.md` §Bounded-resources policy,
//! the "concurrent channels per session — ours" row.) The in-crate //! the "concurrent channels per session: ours" row.) The in-crate
//! concurrent-channel cap ([`crate::channel_cap::ChannelCap`]) is //! concurrent-channel cap ([`crate::channel_cap::ChannelCap`]) is
//! enforced at every `channel_open_*` hook: an accept path beyond //! enforced at every `channel_open_*` hook: an accept path beyond
//! `Limits::max_concurrent_channels` is refused fail-closed at the //! `Limits::max_concurrent_channels` is refused fail-closed at the
//! open ([`crate::dispatch::cap_exceeded_open_decline`] — never //! open ([`crate::dispatch::cap_exceeded_open_decline`]; never
//! queued, never half-open), and the count releases so the cap //! queued, never half-open), and the count releases so the cap
//! recovers: through the `channel_close` hook for established //! recovers: through the `channel_close` hook for established
//! channels, and — `w3-tunnel-direct` — inside the route-1 bridge's //! channels, and, for `w3-tunnel-direct`, inside the route-1 bridge's
//! spawned task on a fabric-open failure (the admitted-but-rejected //! spawned task on a fabric-open failure (the admitted-but-rejected
//! shape: ADR-006 decision 1 rejects the open, so no `channel_close` //! shape: ADR-006 decision 1 rejects the open, so no `channel_close`
//! can ever fire for it; the release mechanism is the shared //! can ever fire for it; the release mechanism is the shared
@@ -251,14 +251,14 @@
//! its two budget surfaces (`w3-tunnel-listen`, recorded in //! its two budget surfaces (`w3-tunnel-listen`, recorded in
//! `channel_cap.rs`): the arrivals admit into the owning route's //! `channel_cap.rs`): the arrivals admit into the owning route's
//! count inside the spawned arrival tasks (a beyond-cap arrival never //! count inside the spawned arrival tasks (a beyond-cap arrival never
//! opens an SSH channel — teardown-at-birth), and the listen count is //! opens an SSH channel, which is teardown-at-birth), and the listen count is
//! its own dedicated counter against the same ceiling (the //! its own dedicated counter against the same ceiling (the
//! listener/channels mix stays unconstrained; both stay bounded). The //! listener/channels mix stays unconstrained; both stay bounded). The
//! order of the //! order of the
//! gates: the feature gate first (an unfeatured build declines //! gates: the feature gate first (an unfeatured build declines
//! everything before the cap matters), then the pure-decline classes //! everything before the cap matters), then the pure-decline classes
//! (the tunnel rows' unset-handle/unix-gate/identity/invalid-target //! (the tunnel rows' unset-handle/unix-gate/identity/invalid-target
//! checks precede the admission — decline-first-alloc-never applies //! checks precede the admission; decline-first-alloc-never applies
//! to the budget too), the cap admission before any accept; the //! to the budget too), the cap admission before any accept; the
//! admitted count is held across the fabric open and released exactly //! admitted count is held across the fabric open and released exactly
//! once on either path. The `max_exec_command_len` cap is //! once on either path. The `max_exec_command_len` cap is
@@ -272,7 +272,7 @@
//! The handler type is deliberately constructible outside this module //! The handler type is deliberately constructible outside this module
//! (re-exported `#[doc(hidden)]` from the crate root) so the wave-1 //! (re-exported `#[doc(hidden)]` from the crate root) so the wave-1
//! contract tests drive these hooks directly against the in-memory //! contract tests drive these hooks directly against the in-memory
//! doubles — the wire-facing `VerifiedPublicKey`/`PublicKeyOffer` hook //! doubles; the wire-facing `VerifiedPublicKey`/`PublicKeyOffer` hook
//! arguments are `#[non_exhaustive]` (only russh constructs them), so //! arguments are `#[non_exhaustive]` (only russh constructs them), so
//! the bridge core is its own method ([`SessionHandler::resolve_bridge`]) //! the bridge core is its own method ([`SessionHandler::resolve_bridge`])
//! the tests call with ordinary key material; the wire-level rows //! the tests call with ordinary key material; the wire-level rows
@@ -308,36 +308,36 @@ use crate::tunnel_map;
use alktty::negotiation::NegotiateRequest; use alktty::negotiation::NegotiateRequest;
/// The per-connection handler (one per mounted stream): russh constructs /// The per-connection handler (one per mounted stream): russh constructs
/// nothing here — [`crate::SshAdapter::handle`] builds one per accepted /// nothing here; [`crate::SshAdapter::handle`] builds one per accepted
/// stream and drives it through `russh::server::run_stream`. /// stream and drives it through `russh::server::run_stream`.
/// ///
/// Carries the serve-config the auth bridge and dispatch consume, the /// Carries the serve-config the auth bridge and dispatch consume, the
/// in-crate concurrent-channel cap (`ChannelCap` — see the private /// in-crate concurrent-channel cap (`ChannelCap`, see the private
/// `channel_cap` module; enforced at every `channel_open_*` accept /// `channel_cap` module; enforced at every `channel_open_*` accept
/// path, bounded resources, AGENTS.md convention 9), and the resolved /// path, bounded resources, AGENTS.md convention 9), and the resolved
/// identity once a publickey auth has been accepted (see /// identity once a publickey auth has been accepted (see
/// [`Self::resolved_identity`] — the wave-3 seam reads it). /// [`Self::resolved_identity`], which the wave-3 seam reads).
pub struct SessionHandler { pub struct SessionHandler {
serve: Arc<ServeConfig>, serve: Arc<ServeConfig>,
/// The in-crate concurrent-channel cap (`w2-bounded-resources`), /// The in-crate concurrent-channel cap (`w2-bounded-resources`),
/// shared as an `Arc` since `w3-tunnel-direct`: the route-1 bridge's /// shared as an `Arc` since `w3-tunnel-direct`: the route-1 bridge's
/// spawned task releases the count on a fabric-open failure (the /// spawned task releases the count on a fabric-open failure (the
/// admitted-but-rejected shape — no `channel_close` can ever fire /// admitted-but-rejected shape, no `channel_close` can ever fire
/// for it; see `channel_cap.rs`'s release-sites record). Admissions /// for it; see `channel_cap.rs`'s release-sites record). Admissions
/// have two sites since `w3-tunnel-listen` — the packet loop's /// have two sites since `w3-tunnel-listen`: the packet loop's
/// open hooks and the `-R` arrival tasks — and releases three; the /// open hooks and the `-R` arrival tasks, and releases three; the
/// mutex makes them sound (the reasoning, recorded in /// mutex makes them sound (the reasoning, recorded in
/// `channel_cap.rs`). /// `channel_cap.rs`).
channel_cap: Arc<ChannelCap>, channel_cap: Arc<ChannelCap>,
/// The `-R` listen leg's per-session bookkeeping /// The `-R` listen leg's per-session bookkeeping
/// (`w3-tunnel-listen`): the minted-listen map (token → pending /// (`w3-tunnel-listen`): the minted-listen map (token → pending
/// listen; written only by the packet loop's hooks — the listen /// listen; written only by the packet loop's hooks. The listen
/// global requests mints, the cancel forms + this handler's /// global requests mints, the cancel forms + this handler's
/// teardown remove, single-writer/no-lock like the `channels` /// teardown remove, single-writer/no-lock like the `channels`
/// store) and the listen count's dedicated /// store) and the listen count's dedicated
/// `max_concurrent_channels`-ceiling counter (a listen is at least /// `max_concurrent_channels`-ceiling counter (a listen is at least
/// one future channel's potential; its own counter keeps the /// one future channel's potential; its own counter keeps the
/// listener/channels mix unconstrained while both stay bounded — /// listener/channels mix unconstrained while both stay bounded,
/// no unbounded listener map). /// no unbounded listener map).
#[cfg(feature = "tunnel")] #[cfg(feature = "tunnel")]
listeners: HashMap<u32, tunnel_map::PendingListen>, listeners: HashMap<u32, tunnel_map::PendingListen>,
@@ -349,7 +349,7 @@ pub struct SessionHandler {
/// the close releases them. Written only by the packet loop's /// the close releases them. Written only by the packet loop's
/// hooks (`channel_open_session` inserts; the git row's and the /// hooks (`channel_open_session` inserts; the git row's and the
/// session rows' serving dispatches remove for their taken /// session rows' serving dispatches remove for their taken
/// channel; `channel_close` removes) — single-writer, no lock. /// channel; `channel_close` removes); single-writer, no lock.
channels: HashMap<ChannelId, Channel<Msg>>, channels: HashMap<ChannelId, Channel<Msg>>,
/// The session rows' negotiated pty params (`w3-tty-session`): /// The session rows' negotiated pty params (`w3-tty-session`):
/// `pty_request` stores, the later `shell_request`/`exec_request` /// `pty_request` stores, the later `shell_request`/`exec_request`
@@ -376,17 +376,17 @@ impl Drop for SessionHandler {
/// **session-row** serving task of this connection watches it and /// **session-row** serving task of this connection watches it and
/// cancels its alktty session (the kill-on-drop cleanup) instead /// cancels its alktty session (the kill-on-drop cleanup) instead
/// of outliving the connection. The git serving task does not /// of outliving the connection. The git serving task does not
/// watch this signal — it ends through the channel duplex (stream /// watch this signal; it ends through the channel duplex (stream
/// EOF / send failure) once the russh session dies, bounded by the /// EOF / send failure) once the russh session dies, bounded by the
/// alkgit seam's own `session_wall_clock` (the alkgit limits the /// alkgit seam's own `session_wall_clock` (the alkgit limits the
/// assembly configured). The `tunnel` build adds the `-R` /// assembly configured). The `tunnel` build adds the `-R`
/// leg's teardown (`w3-tunnel-listen`): the session's pending /// leg's teardown (`w3-tunnel-listen`): the session's pending
/// listens' tokens leave the shared router (a fabric arrival after /// listens' tokens leave the shared router (a fabric arrival after
/// the session is gone is the correlation-miss class — /// the session is gone is the correlation-miss class,
/// teardown-at-birth) and the listener objects drop here (the /// teardown-at-birth) and the listener objects drop here (the
/// fabric-side reap). Any in-flight arrival already past the /// fabric-side reap). Any in-flight arrival already past the
/// route lookup fails its dead-handle open and releases its /// route lookup fails its dead-handle open and releases its
/// admission — the never-established path. /// admission, the never-established path.
fn drop(&mut self) { fn drop(&mut self) {
#[cfg(feature = "session")] #[cfg(feature = "session")]
self.teardown.fire(); self.teardown.fire();
@@ -401,7 +401,7 @@ impl Drop for SessionHandler {
} }
impl SessionHandler { impl SessionHandler {
/// Builds the handler over the serving configuration — the /// Builds the handler over the serving configuration; the
/// identity provider inside is the assembly-supplied /// identity provider inside is the assembly-supplied
/// `alkcall::core::auth::IdentityProvider`; the bridge only /// `alkcall::core::auth::IdentityProvider`; the bridge only
/// resolves through it. /// resolves through it.
@@ -428,14 +428,14 @@ impl SessionHandler {
/// store-and-take handoff): the git row's and the session rows' /// store-and-take handoff): the git row's and the session rows'
/// dispatches remove the handle the open stored, handing the /// dispatches remove the handle the open stored, handing the
/// stream into the spawned serving task. `None` when nothing is /// stream into the spawned serving task. `None` when nothing is
/// held — a re-served or never-held channel (unvanilla; declined /// held: a re-served or never-held channel (unvanilla; declined
/// serving-path). /// serving-path).
#[cfg(any(feature = "git", feature = "session"))] #[cfg(any(feature = "git", feature = "session"))]
fn take_stored_channel(&mut self, channel: ChannelId) -> Option<Channel<Msg>> { fn take_stored_channel(&mut self, channel: ChannelId) -> Option<Channel<Msg>> {
self.channels.remove(&channel) self.channels.remove(&channel)
} }
/// The identity this connection currently rides — `Some` only after /// The identity this connection currently rides, `Some` only after
/// a fingerprint resolution accepted a publickey auth (the bridge's /// a fingerprint resolution accepted a publickey auth (the bridge's
/// fail-closed rejecting paths never leave a weakly-authorized /// fail-closed rejecting paths never leave a weakly-authorized
/// state behind). The wave-3 seam propagation consumes this. /// state behind). The wave-3 seam propagation consumes this.
@@ -447,15 +447,15 @@ impl SessionHandler {
/// cap (bounded resources, `w2-bounded-resources`): room under /// cap (bounded resources, `w2-bounded-resources`): room under
/// `Limits::max_concurrent_channels` admits and counts the channel; /// `Limits::max_concurrent_channels` admits and counts the channel;
/// beyond the cap the open is refused fail-closed with a clean /// beyond the cap the open is refused fail-closed with a clean
/// wire reason ([`crate::dispatch::cap_exceeded_open_decline`]) — /// wire reason ([`crate::dispatch::cap_exceeded_open_decline`]),
/// never queued, never half-open. Called exactly at accept sites — /// never queued, never half-open. Called exactly at accept sites:
/// the packet loop owns every admit call (the single admit writer, /// the packet loop owns every admit call (the single admit writer,
/// [`ChannelCap`]'s recorded reasoning); a pure-decline path /// [`ChannelCap`]'s recorded reasoning); a pure-decline path
/// (unfeatured, never-feature, the tunnel rows' pre-admit classes) /// (unfeatured, never-feature, the tunnel rows' pre-admit classes)
/// never touches the count. Since `w3-tunnel-direct`, the tunnel /// never touches the count. Since `w3-tunnel-direct`, the tunnel
/// rows admit **at the hook and hold the count across the fabric /// rows admit **at the hook and hold the count across the fabric
/// open**: a fabric failure releases inside the spawned bridge /// open**: a fabric failure releases inside the spawned bridge
/// task (the admitted-but-rejected shape — no `channel_close` can /// task (the admitted-but-rejected shape; no `channel_close` can
/// ever fire); a success releases when the established channel /// ever fire); a success releases when the established channel
/// closes ([`Self::channel_close`]). The release sites are disjoint /// closes ([`Self::channel_close`]). The release sites are disjoint
/// per admission by the wire shape. /// per admission by the wire shape.
@@ -467,16 +467,16 @@ impl SessionHandler {
/// The ADR-004 decision 1 bridge core, structured out of the /// The ADR-004 decision 1 bridge core, structured out of the
/// [`Handler::auth_publickey`] hook so the contract tests can drive /// [`Handler::auth_publickey`] hook so the contract tests can drive
/// it directly: the hook's russh-owned argument shape /// it directly: the hook's russh-owned argument shape
/// (`russh::server::VerifiedPublicKey`) is `#[non_exhaustive]` — /// (`russh::server::VerifiedPublicKey`) is `#[non_exhaustive]`,
/// only russh constructs it — but this method takes the two facts /// only russh constructs it, but this method takes the two facts
/// the hook receives (the verified key and the signature's /// the hook receives (the verified key and the signature's
/// algorithm) as ordinary arguments. /// algorithm) as ordinary arguments.
/// ///
/// Ed25519-only gate first (ADR-002 decision 4 — `SkEd25519` and /// Ed25519-only gate first (ADR-002 decision 4: `SkEd25519` and
/// every other scheme share the non-ed25519 decline, which is /// every other scheme share the non-ed25519 decline, which is
/// explicit-over-inherited so it survives a pin bump), then the /// explicit-over-inherited so it survives a pin bump), then the
/// single fingerprint path → the injected provider's /// single fingerprint path → the injected provider's
/// `resolve_from_fingerprint`. No username parameter exists — the /// `resolve_from_fingerprint`. No username parameter exists; the
/// user string is never an identity input (OQ-03). `None` is the /// user string is never an identity input (OQ-03). `None` is the
/// fail-closed reject disposition; `Some` carries the identity the /// fail-closed reject disposition; `Some` carries the identity the
/// hook stores in handler state. /// hook stores in handler state.
@@ -503,7 +503,7 @@ impl SessionHandler {
/// The `session` channel-open gate (ADR-003 feature graph): /// The `session` channel-open gate (ADR-003 feature graph):
/// session channels open only where a session-serving feature /// session channels open only where a session-serving feature
/// exists — `session` ∨ `git`, because git's parse path lives /// exists: `session` ∨ `git`, because git's parse path lives
/// inside session channels. /// inside session channels.
fn serves_session_channels(&self) -> bool { fn serves_session_channels(&self) -> bool {
cfg!(feature = "session") || cfg!(feature = "git") cfg!(feature = "session") || cfg!(feature = "git")
@@ -554,7 +554,7 @@ impl russh::server::Handler for SessionHandler {
} }
} }
/// ADR-004 decision 2: accept-and-delegate, explicit — the probe /// ADR-004 decision 2: accept-and-delegate, explicit; the probe
/// hook consumes no auth attempt (POC-1 verified), so keeping it at /// hook consumes no auth attempt (POC-1 verified), so keeping it at
/// `Auth::Accept` keeps the `max_auth_attempts` counting honest; /// `Auth::Accept` keeps the `max_auth_attempts` counting honest;
/// the real disposition is [`Self::auth_publickey`]'s. /// the real disposition is [`Self::auth_publickey`]'s.
@@ -566,14 +566,14 @@ impl russh::server::Handler for SessionHandler {
Ok(Auth::Accept) Ok(Auth::Accept)
} }
/// ADR-004 decision 3: explicit family-contract decline — never the /// ADR-004 decision 3: explicit family-contract decline, never the
/// inherited default (a pin bump could silently change it). /// inherited default (a pin bump could silently change it).
async fn auth_none(&mut self, _user: &str) -> Result<Auth, Self::Error> { async fn auth_none(&mut self, _user: &str) -> Result<Auth, Self::Error> {
observe::decline_auth("auth_none", "method-unimplemented"); observe::decline_auth("auth_none", "method-unimplemented");
Ok(Auth::reject()) Ok(Auth::reject())
} }
/// ADR-004 decision 3: explicit family-contract decline — password /// ADR-004 decision 3: explicit family-contract decline; password
/// auth does not exist on this door; ADR-005's key custody is the /// auth does not exist on this door; ADR-005's key custody is the
/// whole story. /// whole story.
async fn auth_password(&mut self, _user: &str, _password: &str) -> Result<Auth, Self::Error> { async fn auth_password(&mut self, _user: &str, _password: &str) -> Result<Auth, Self::Error> {
@@ -581,7 +581,7 @@ impl russh::server::Handler for SessionHandler {
Ok(Auth::reject()) Ok(Auth::reject())
} }
/// ADR-004 decision 3 + OQ-04 (resolved): explicit decline — the /// ADR-004 decision 3 + OQ-04 (resolved): explicit decline, since the
/// CA-trust story does not exist in the family identity model; /// CA-trust story does not exist in the family identity model;
/// future support is new-ADR territory. /// future support is new-ADR territory.
async fn auth_openssh_certificate( async fn auth_openssh_certificate(
@@ -604,16 +604,16 @@ impl russh::server::Handler for SessionHandler {
Ok(Auth::reject()) Ok(Auth::reject())
} }
/// A counted channel closed — the in-crate concurrent-channel /// A counted channel closed: the in-crate concurrent-channel
/// cap's decrement (`w2-bounded-resources`): every fired /// cap's decrement (`w2-bounded-resources`): every fired
/// `channel_close` corresponds to exactly one previously-admitted /// `channel_close` corresponds to exactly one previously-admitted
/// open (russh only routes `CHANNEL_CLOSE` for established /// open (russh only routes `CHANNEL_CLOSE` for established
/// channels — a declined open never established one — and consumes /// channels, a declined open never established one, and consumes
/// the close at most once), so the count recovers through this /// the close at most once), so the count recovers through this
/// single release point, saturating in [`ChannelCap::release`] on /// single release point, saturating in [`ChannelCap::release`] on
/// top. The spawned serving paths never touch the count directly: /// top. The spawned serving paths never touch the count directly:
/// their closes ride the cloned handle, the client's close reply /// their closes ride the cloned handle, the client's close reply
/// cycles back through the packet loop, and this hook fires — the /// cycles back through the packet loop, and this hook fires; the
/// single-writer reasoning in [`ChannelCap`]. /// single-writer reasoning in [`ChannelCap`].
async fn channel_close( async fn channel_close(
&mut self, &mut self,
@@ -634,15 +634,15 @@ impl russh::server::Handler for SessionHandler {
/// The session-channel open gate (ADR-003 feature graph, /// The session-channel open gate (ADR-003 feature graph,
/// `session` ∨ `git`), then the in-crate concurrent-channel cap /// `session` ∨ `git`), then the in-crate concurrent-channel cap
/// ([`Self::admit_channel_open`] — bounded resources, /// ([`Self::admit_channel_open`], bounded resources,
/// `w2-bounded-resources`): unfeatured builds decline at the open /// `w2-bounded-resources`): unfeatured builds decline at the open
/// — never a half-open channel — and a featured open beyond the /// and never a half-open channel; a featured open beyond the
/// cap is refused fail-closed with a clean wire reason /// cap is refused fail-closed with a clean wire reason
/// ([`crate::dispatch::cap_exceeded_open_decline`]), never queued. /// ([`crate::dispatch::cap_exceeded_open_decline`]), never queued.
/// A confirmed open accepts the channel and counts it; the count /// A confirmed open accepts the channel and counts it; the count
/// releases when the channel closes ([`Self::channel_close`]). The /// releases when the channel closes ([`Self::channel_close`]). The
/// parse-only dispatch serves the accepted channel through its /// parse-only dispatch serves the accepted channel through its
/// requests (nothing serves request-less — ADR-006 as amended). /// requests (nothing serves request-less, per ADR-006 as amended).
async fn channel_open_session( async fn channel_open_session(
&mut self, &mut self,
channel: Channel<Msg>, channel: Channel<Msg>,
@@ -664,7 +664,7 @@ impl russh::server::Handler for SessionHandler {
Ok(()) Ok(())
} }
/// The x11 channel open: **always declines** — a never-feature /// The x11 channel open: **always declines**, a never-feature
/// (ADR-003 §5), declined in every build, never feature-gated. The /// (ADR-003 §5), declined in every build, never feature-gated. The
/// cap never applies here: a count is consumed only at an accept /// cap never applies here: a count is consumed only at an accept
/// path, and this hook never has one. /// path, and this hook never has one.
@@ -684,14 +684,14 @@ impl russh::server::Handler for SessionHandler {
/// The `direct-tcpip` open gate (`tunnel`) and its route-1 bridge /// The `direct-tcpip` open gate (`tunnel`) and its route-1 bridge
/// dispatch (`w3-tunnel-direct`): unfeatured declines at the open; /// dispatch (`w3-tunnel-direct`): unfeatured declines at the open;
/// featured rows build the bridge target from the SSH open /// featured rows build the bridge target from the SSH open
/// arguments (`SubstrateAddr::HostPort` + `Substrate::Tcp` — an /// arguments (`SubstrateAddr::HostPort` + `Substrate::Tcp`; an
/// out-of-range wire port is an impossible target, declined /// out-of-range wire port is an impossible target, declined
/// pre-admission) and route to [`Self::dispatch_direct_open`]: /// pre-admission) and route to [`Self::dispatch_direct_open`]:
/// pre-admit decline classes first (the unset fabric handle /// pre-admit decline classes first (the unset fabric handle
/// declining under the feature — the unset-declines posture — and /// declining under the feature, the unset-declines posture, and
/// the unreachable-in-practice `no-identity` guard), then the cap /// the unreachable-in-practice `no-identity` guard), then the cap
/// admission **at the hook, held across the fabric open**, then /// admission **at the hook, held across the fabric open**, then
/// the spawned bridge per spawn-and-return — /// the spawned bridge per spawn-and-return:
/// `TunnelSession::open_direct` → accept only after the fabric /// `TunnelSession::open_direct` → accept only after the fabric
/// open succeeds (`ConnectFailed` on failure, the count released /// open succeeds (`ConnectFailed` on failure, the count released
/// in the task), `take_halves()` pumped against the taken channel. /// in the task), `take_halves()` pumped against the taken channel.
@@ -737,7 +737,7 @@ impl russh::server::Handler for SessionHandler {
} }
/// The `forwarded-tcpip` open gate (`tunnel`): an **inbound** open /// The `forwarded-tcpip` open gate (`tunnel`): an **inbound** open
/// of this channel type — arriving *from* the vanilla client — is /// of this channel type (arriving *from* the vanilla client) is
/// not the `-R` leg at all (the `-R` data leg is the door's own /// not the `-R` leg at all (the `-R` data leg is the door's own
/// server-initiated opens via [`crate::tunnel_map`]'s arrival /// server-initiated opens via [`crate::tunnel_map`]'s arrival
/// pump; a vanilla client never initiates `forwarded-tcpip`, /// pump; a vanilla client never initiates `forwarded-tcpip`,
@@ -769,10 +769,10 @@ impl russh::server::Handler for SessionHandler {
/// The `direct-streamlocal` open gate (`tunnel`) and its route-1 /// The `direct-streamlocal` open gate (`tunnel`) and its route-1
/// bridge dispatch: the `direct-tcpip` shape with the OQ-11 /// bridge dispatch: the `direct-tcpip` shape with the OQ-11
/// **runtime-Unix gate** — the row compiles wherever `tunnel` /// **runtime-Unix gate**: the row compiles wherever `tunnel`
/// compiles (universal compile, no per-platform feature fork), but /// compiles (universal compile, no per-platform feature fork), but
/// off-Unix it declines at the open **before any fabric call** /// off-Unix it declines at the open **before any fabric call**
/// (never a partial bridge — the gate precedes the unset-handle /// (never a partial bridge; the gate precedes the unset-handle
/// check, the identity guard, and the cap admission), and on-Unix /// check, the identity guard, and the cap admission), and on-Unix
/// it rides the same bridge with `SubstrateAddr::Path` + /// it rides the same bridge with `SubstrateAddr::Path` +
/// `Substrate::Unix`. /// `Substrate::Unix`.
@@ -821,9 +821,9 @@ impl russh::server::Handler for SessionHandler {
/// The exec-grammar dispatch (ADR-006 point 3 as amended; the /// The exec-grammar dispatch (ADR-006 point 3 as amended; the
/// full grammar, `w3-git-mapping`): the per-length cap checks the /// full grammar, `w3-git-mapping`): the per-length cap checks the
/// wire bytes first (pre-parse, pre-allocation — decline first, /// wire bytes first (pre-parse, pre-allocation; decline first,
/// alloc never), then [`crate::dispatch::parse_exec`] routes: /// alloc never), then [`crate::dispatch::parse_exec`] routes:
/// a git route to the git row (feature-gated — a session-only /// a git route to the git row (feature-gated; a session-only
/// build declines it as its gate), a grammar declinable to its /// build declines it as its gate), a grammar declinable to its
/// honest request-level decline, anything else to the session /// honest request-level decline, anything else to the session
/// pipe row. Invalid UTF-8 parses to the pipe row (parse-then- /// pipe row. Invalid UTF-8 parses to the pipe row (parse-then-
@@ -889,11 +889,11 @@ impl russh::server::Handler for SessionHandler {
/// The `pty_request` row (`session`): the args → /// The `pty_request` row (`session`): the args →
/// `TerminalParamsWire` (ttymodes → the reserved `{}` JSON, /// `TerminalParamsWire` (ttymodes → the reserved `{}` JSON,
/// OQ-44), stored per channel for the later shell/exec request on /// OQ-44), stored per channel for the later shell/exec request on
/// the same channel — after the seam check (a pty whose session /// the same channel, after the seam check (a pty whose session
/// can never serve is a declined allocation, /// can never serve is a declined allocation,
/// `seam-unconfigured`). Unfeatured builds decline the /// `seam-unconfigured`). Unfeatured builds decline the
/// capability. The successful row answers `channel_success` /// capability. The successful row answers `channel_success`
/// (POC-3 §D.1's shape — serving starts at the shell/exec /// (POC-3 §D.1's shape: serving starts at the shell/exec
/// request). /// request).
async fn pty_request( async fn pty_request(
&mut self, &mut self,
@@ -930,11 +930,11 @@ impl russh::server::Handler for SessionHandler {
/// The `shell_request` row (`session`): the serve-config shell /// The `shell_request` row (`session`): the serve-config shell
/// argv, verbatim, as `NegotiateRequest.cmd`; the stored pty /// argv, verbatim, as `NegotiateRequest.cmd`; the stored pty
/// params consumed (`None` → pipe-mode shell — decided: a pty-less /// params consumed (`None` → pipe-mode shell; decided: a pty-less
/// shell request is served, `ssh -T host` is legitimate vanilla); /// shell request is served, `ssh -T host` is legitimate vanilla);
/// unset-declines first (OQ-08: `shell_argv` unset → /// unset-declines first (OQ-08: `shell_argv` unset →
/// `shell-unconfigured`; the seam unset → `seam-unconfigured` /// `shell-unconfigured`; the seam unset → `seam-unconfigured`
/// — both the spawned serving-path shape). A routed request /// (both the spawned serving-path shape). A routed request
/// answers `channel_success` inline; the exit chunk reports the /// answers `channel_success` inline; the exit chunk reports the
/// outcome, status-before-close from the spawned task. /// outcome, status-before-close from the spawned task.
async fn shell_request( async fn shell_request(
@@ -998,13 +998,13 @@ impl russh::server::Handler for SessionHandler {
Ok(()) Ok(())
} }
/// `window_change_request` (`session`): the resize control row — /// `window_change_request` (`session`): the resize control row,
/// forwarded into the serving bridge's bounded control surface /// forwarded into the serving bridge's bounded control surface
/// (`try_send`, never awaited on the packet loop); for a channel /// (`try_send`, never awaited on the packet loop); for a channel
/// whose pty is stored but not yet serving, the stored params /// whose pty is stored but not yet serving, the stored params
/// resize instead (the final size wins — vanilla sshd's own /// resize instead (the final size wins, vanilla sshd's own
/// ordering); with neither, the event is dropped. Fire-and-forget /// ordering); with neither, the event is dropped. Fire-and-forget
/// on the wire — no SSH reply exists, so a decline sends none /// on the wire; no SSH reply exists, so a decline sends none
/// (the event is the record). Signal names: see [`Self::signal`]. /// (the event is the record). Signal names: see [`Self::signal`].
async fn window_change_request( async fn window_change_request(
&mut self, &mut self,
@@ -1050,11 +1050,11 @@ impl russh::server::Handler for SessionHandler {
Ok(()) Ok(())
} }
/// The `signal` row (`session`): accepted verbatim — no /// The `signal` row (`session`): accepted verbatim, with no
/// alkssh-side name filter (the backend owns the semantics; /// alkssh-side name filter (the backend owns the semantics;
/// alktty's control handle applies its own default kill for /// alktty's control handle applies its own default kill for
/// names it rejects, and the pipe-mode pid-only vs PTY /// names it rejects, and the pipe-mode pid-only vs PTY
/// process-group scope is backend semantics) — so there is no /// process-group scope is backend semantics), so there is no
/// decline shape: the request is fire-and-forget on the SSH /// decline shape: the request is fire-and-forget on the SSH
/// wire, and the bridge's bounded surface is the only path. An /// wire, and the bridge's bounded surface is the only path. An
/// unfeatured build emits the decline event with no reply. /// unfeatured build emits the decline event with no reply.
@@ -1082,7 +1082,7 @@ impl russh::server::Handler for SessionHandler {
} }
/// Every subsystem name declines (unimplemented on this door): /// Every subsystem name declines (unimplemented on this door):
/// `subsystem("sftp")` is the OQ-13 placeholder — the feature /// `subsystem("sftp")` is the OQ-13 placeholder; the feature
/// exists, the route does not until alkfs lands; git service /// exists, the route does not until alkfs lands; git service
/// names carry only a name, never a repository id, so git can /// names carry only a name, never a repository id, so git can
/// never dispatch as a subsystem (ADR-006 decision 2, POC-3 /// never dispatch as a subsystem (ADR-006 decision 2, POC-3
@@ -1101,7 +1101,7 @@ impl russh::server::Handler for SessionHandler {
} }
/// `env_request` declines unconditionally (OQ-09: no allowlist /// `env_request` declines unconditionally (OQ-09: no allowlist
/// ships — wire env is not family config; the assembly supplies /// ships, since wire env is not family config; the assembly supplies
/// the session env through [`crate::ServeConfig::session_env`]). /// the session env through [`crate::ServeConfig::session_env`]).
async fn env_request( async fn env_request(
&mut self, &mut self,
@@ -1132,7 +1132,7 @@ impl russh::server::Handler for SessionHandler {
} }
/// Agent forwarding on a session session-channel: always declines /// Agent forwarding on a session session-channel: always declines
/// (never-feature, ADR-003 §5 — agent forwarding is the /// (never-feature, ADR-003 §5; agent forwarding is the
/// anti-pattern of the alkvault custody model). `false` is the /// anti-pattern of the alkvault custody model). `false` is the
/// decline; russh replies the channel failure. /// decline; russh replies the channel failure.
async fn agent_request( async fn agent_request(
@@ -1146,7 +1146,7 @@ impl russh::server::Handler for SessionHandler {
/// The `tcpip-forward` listen request (`-R` ingress, `w3-tunnel- /// The `tcpip-forward` listen request (`-R` ingress, `w3-tunnel-
/// listen`): the gate is [`Self::serves_tunnels`]; the real flow /// listen`): the gate is [`Self::serves_tunnels`]; the real flow
/// rides [`listen_request_dispatch`] (`tunnel` builds) — the /// rides [`listen_request_dispatch`] (`tunnel` builds), the
/// family-ward `TunnelListener::open` dial, the bound-port out-param /// family-ward `TunnelListener::open` dial, the bound-port out-param
/// (`port = 0` → the fabric's actually-bound number, the sshd /// (`port = 0` → the fabric's actually-bound number, the sshd
/// parity), the route + listener registration. Unfeatured builds /// parity), the route + listener registration. Unfeatured builds
@@ -1179,13 +1179,13 @@ impl russh::server::Handler for SessionHandler {
} }
/// The `cancel-tcpip-forward` cancel form (`-KR`, mux teardown, /// The `cancel-tcpip-forward` cancel form (`-KR`, mux teardown,
/// `ExitOnForwardFailure`): 1:1 into the family listener's teardown — /// `ExitOnForwardFailure`): 1:1 into the family listener's teardown,
/// the pending listen matched by (the wire address as the request /// the pending listen matched by (the wire address as the request
/// carried it, the bound port), the route removed **before** any /// carried it, the bound port), the route removed **before** any
/// teardown (a racing arrival is already the correlation-miss /// teardown (a racing arrival is already the correlation-miss
/// class), the listener count released, the fabric channel's /// class), the listener count released, the fabric channel's
/// teardown spawned (`TunnelListener::close`). `false` for a /// teardown spawned (`TunnelListener::close`). `false` for a
/// (address, port) pair with no pending listen — the sshd parity /// (address, port) pair with no pending listen; the sshd parity
/// (`cancel of a not-found forward`). /// (`cancel of a not-found forward`).
async fn cancel_tcpip_forward( async fn cancel_tcpip_forward(
&mut self, &mut self,
@@ -1206,7 +1206,7 @@ impl russh::server::Handler for SessionHandler {
} }
/// The `streamlocal-forward` listen request: the same listen flow /// The `streamlocal-forward` listen request: the same listen flow
/// as `tcpip_forward` over the unix substrate — with the OQ-11 /// as `tcpip_forward` over the unix substrate, with the OQ-11
/// runtime-Unix gate riding the serving flow it serves (off-Unix /// runtime-Unix gate riding the serving flow it serves (off-Unix
/// it declines before any fabric call; universal compile, no /// it declines before any fabric call; universal compile, no
/// per-platform feature fork). No port out-param (the unix listen /// per-platform feature fork). No port out-param (the unix listen
@@ -1271,34 +1271,34 @@ impl russh::server::Handler for SessionHandler {
/// gate order is decline-first-alloc-never, each consumer checked /// gate order is decline-first-alloc-never, each consumer checked
/// before the next allocation is taken: /// before the next allocation is taken:
/// ///
/// 1. **The `no-identity` guard** — unreachable on the wire (russh /// 1. **The `no-identity` guard**: unreachable on the wire (russh
/// authorizes before requests serve) and fail-closed: never /// authorizes before requests serve) and fail-closed: never
/// anonymous listens. /// anonymous listens.
/// 2. **The seam checks** — the fabric handle or the listen seam /// 2. **The seam checks**: the fabric handle or the listen seam
/// (`listen_router` absent: an egress-only door — the seam unset or /// (`listen_router` absent: an egress-only door, whether the seam unset or
/// the registration never ran) decline `seam-unconfigured` before /// the registration never ran) decline `seam-unconfigured` before
/// any dial. /// any dial.
/// 3. **The OQ-11 unix gate** — the streamlocal variant's off-Unix /// 3. **The OQ-11 unix gate**: the streamlocal variant's off-Unix
/// decline rides its hook (checked before this fn runs, so the /// decline rides its hook (checked before this fn runs, so the
/// unreachable-on-Unix-only row never here). /// unreachable-on-Unix-only row never here).
/// 4. **The listener cap** — the listen count's dedicated /// 4. **The listener cap**: the listen count's dedicated
/// `max_concurrent_channels`-ceiling admission; beyond it /// `max_concurrent_channels`-ceiling admission; beyond it
/// `cap-exceeded`. /// `cap-exceeded`.
/// ///
/// Then the family-ward dial ([`crate::tunnel_map::open_listen`], the /// Then the family-ward dial ([`crate::tunnel_map::open_listen`], the
/// `resource = <the wire bind endpoint>` naming convention) — awaited /// `resource = <the wire bind endpoint>` naming convention), awaited
/// inline (the recorded inline decision: the global request's reply + /// inline (the recorded inline decision: the global request's reply +
/// the `&mut u32` out-param are the hook's; the fabric answer rides /// the `&mut u32` out-param are the hook's; the fabric answer rides
/// the fabric leg, not this session's packet loop; the dial is /// the fabric leg, not this session's packet loop; the dial is
/// bounded by `LISTEN_DIAL_TIMEOUT`, so the stall is bounded). The /// bounded by `LISTEN_DIAL_TIMEOUT`, so the stall is bounded). The
/// reply's `bound` port writes the out-param (`port = 0` → the /// reply's `bound` port writes the out-param (`port = 0` → the
/// actually-bound number, sshd parity); the route registers into the /// actually-bound number, sshd parity); the route registers into the
/// shared router — the register is the collision pre-check /// shared router; the register is the collision pre-check
/// (`w4-router-collision`): a mint on an already-held token refuses /// (`w4-router-collision`): a mint on an already-held token refuses
/// fail-closed (`token-collision`, the minted listener torn down, the /// fail-closed (`token-collision`, the minted listener torn down, the
/// cap released — a collided mint can never steal a live route; the /// cap released, so a collided mint can never steal a live route; the
/// decline rides `Ok(false)`, never the error taxonomy, since a /// decline rides `Ok(false)`, never the error taxonomy, since a
/// global-request hook's `Err` would kill the whole session) — and /// global-request hook's `Err` would kill the whole session), and
/// the pending listen into the session's /// the pending listen into the session's
/// packet-loop-only map. `Ok(true)`'s answer is the only success /// packet-loop-only map. `Ok(true)`'s answer is the only success
/// shape; every decline path answers the vanilla client's /// shape; every decline path answers the vanilla client's
@@ -1400,15 +1400,15 @@ async fn listen_request_dispatch(
/// listener's teardown. Match rule (sshd parity): the wire endpoint as /// listener's teardown. Match rule (sshd parity): the wire endpoint as
/// the original request carried it + the bound port for the tcp /// the original request carried it + the bound port for the tcp
/// variants (the client sends the same normalized string it sent /// variants (the client sends the same normalized string it sent
/// originally, and the *allocated* port for `port = 0` mints — the /// originally, and the *allocated* port for `port = 0` mints, the
/// value the listen reply carried); the path alone for the unix /// value the listen reply carried); the path alone for the unix
/// variant. Order: the entry leaves the session map and the route /// variant. Order: the entry leaves the session map and the route
/// leaves the shared router before any family teardown spawns (a /// leaves the shared router before any family teardown spawns (a
/// racing arrival that has not yet cloned its route is already the /// racing arrival that has not yet cloned its route is already the
/// correlation-miss class — no post-cancel half-serve; one that /// correlation-miss class, so no post-cancel half-serve; one that
/// cloned just before completes independently — the recorded /// cloned just before completes independently (the recorded
/// caveat), then the listener count releases, then the family /// caveat), then the listener count releases, then the family
/// teardown spawns (`TunnelListener::close` inside the spawn — a /// teardown spawns (`TunnelListener::close` inside the spawn, a
/// bounded local shutdown + entry reap, not a serving path the hook /// bounded local shutdown + entry reap, not a serving path the hook
/// may await). `Ok(true)` iff a pending listen matched. /// may await). `Ok(true)` iff a pending listen matched.
#[cfg(feature = "tunnel")] #[cfg(feature = "tunnel")]
@@ -1449,7 +1449,7 @@ fn cancel_listen_dispatch(
/// The request-level decline shape for an unfeatured/unimplemented /// The request-level decline shape for an unfeatured/unimplemented
/// capability: fail the request and leave the channel usable (POC-1 /// capability: fail the request and leave the channel usable (POC-1
/// §5 — session survives declines, no state poisoning). The decline /// §5: session survives declines, no state poisoning). The decline
/// event is emitted by the caller (the hook carries the row/reason /// event is emitted by the caller (the hook carries the row/reason
/// context); this is the wire shape only. /// context); this is the wire shape only.
async fn decline_unfeatured_request(channel: ChannelId, session: &mut Session) { async fn decline_unfeatured_request(channel: ChannelId, session: &mut Session) {
@@ -1459,16 +1459,16 @@ async fn decline_unfeatured_request(channel: ChannelId, session: &mut Session) {
/// The spawned serving-path decline body (`w3-git-mapping`'s shape, /// The spawned serving-path decline body (`w3-git-mapping`'s shape,
/// reused by `w3-tty-session`'s config-driven declines): fail the /// reused by `w3-tty-session`'s config-driven declines): fail the
/// request and close the channel, through the cloned handle the task /// request and close the channel, through the cloned handle the task
/// was handed — the vanilla-clean shape for a routed row that owns /// was handed, the vanilla-clean shape for a routed row that owns
/// the request's whole outcome. The helper name keeps the wave-3 /// the request's whole outcome. The helper name keeps the wave-3
/// history (the decline IS the path it spawns). Decline-first-alloc- /// history (the decline IS the path it spawns). Decline-first-alloc-
/// never holds structurally: nothing is allocated before the failure /// never holds structurally: nothing is allocated before the failure
/// lands — the spawn's handle clone is the only resource ever taken. /// lands; the spawn's handle clone is the only resource ever taken.
/// ///
/// The serving-path lifecycle events ride this body /// The serving-path lifecycle events ride this body
/// ([`crate::observe`]): started (the task's first action), the /// ([`crate::observe`]): started (the task's first action), the
/// serving-path decline (class `serving`, the caller's vocabulary /// serving-path decline (class `serving`, the caller's vocabulary
/// reason), and — after the wire reporting has completed — ended, or /// reason), and, after the wire reporting has completed, ended, or
/// failed when a report send could not complete (the one warn in the /// failed when a report send could not complete (the one warn in the
/// vocabulary). /// vocabulary).
#[cfg(any(feature = "git", feature = "session"))] #[cfg(any(feature = "git", feature = "session"))]
@@ -1493,7 +1493,7 @@ async fn seam_stub_decline(
/// Spawns a serving-path decline as the row's serving path (ADR-006 /// Spawns a serving-path decline as the row's serving path (ADR-006
/// decision 4): the handle is cloned here, the decline runs on its own /// decision 4): the handle is cloned here, the decline runs on its own
/// task, and the hook returns — no serving path awaits inline. The /// task, and the hook returns; no serving path awaits inline. The
/// row never serves, so the decline *is* the path: failure + close, /// row never serves, so the decline *is* the path: failure + close,
/// event-shaped ([`crate::observe`]) with the caller's reason. /// event-shaped ([`crate::observe`]) with the caller's reason.
#[cfg(any(feature = "git", feature = "session"))] #[cfg(any(feature = "git", feature = "session"))]
@@ -1509,14 +1509,14 @@ fn spawn_seam_stub(
} }
/// The git row's routing stage (feature-gated, `w3-git-mapping`): /// The git row's routing stage (feature-gated, `w3-git-mapping`):
/// validation of the normalized id ([`crate::git_map::repo_id_validates`] — /// validation of the normalized id ([`crate::git_map::repo_id_validates`],
/// reply-shaped `invalid-repo-id`), the seam-unconfigured decline /// reply-shaped `invalid-repo-id`), the seam-unconfigured decline
/// ([`Self::serves_git_row`] alone configures nothing — the /// ([`Self::serves_git_row`] alone configures nothing, per the
/// unset-declines contract), then the store-and-take handoff and the /// unset-declines contract), then the store-and-take handoff and the
/// spawned alkgit dispatch ([`crate::git_map::serve_git_row`], the /// spawned alkgit dispatch ([`crate::git_map::serve_git_row`], the
/// resolved identity riding along). A routed exec answers /// resolved identity riding along). A routed exec answers
/// `channel_success` inline (the request-reply vocabulary: the request /// `channel_success` inline (the request-reply vocabulary: the request
/// routed; the serving outcome reports through exit-status later — /// routed; the serving outcome reports through exit-status later,
/// the POC-3 §A.5 shape), never awaited serving. /// the POC-3 §A.5 shape), never awaited serving.
#[cfg(feature = "git")] #[cfg(feature = "git")]
fn git_row_dispatch( fn git_row_dispatch(
@@ -1569,12 +1569,12 @@ fn git_row_dispatch(
} }
/// The session pipe row's dispatch (`w3-tty-session`): unset-declines /// The session pipe row's dispatch (`w3-tty-session`): unset-declines
/// first (OQ-08 — `exec_wrapper_argv` unset → `wrapper-unconfigured`; /// first (OQ-08: `exec_wrapper_argv` unset → `wrapper-unconfigured`;
/// the seam unset → `seam-unconfigured`; both the spawned serving-path /// the seam unset → `seam-unconfigured`; both the spawned serving-path
/// shape, never a fallback), then the store-and-take handoff /// shape, never a fallback), then the store-and-take handoff
/// (`channel-not-held` → the inline unvanilla decline, the git row's /// (`channel-not-held` → the inline unvanilla decline, the git row's
/// shape), the wire command appended to the wrapper argv (`wrapper ++ /// shape), the wire command appended to the wrapper argv (`wrapper ++
/// [cmd]` — never naively split), the stored pty params consumed if a /// [cmd]`, never naively split), the stored pty params consumed if a
/// pty preceded, and the spawned bridge. A routed exec answers /// pty preceded, and the spawned bridge. A routed exec answers
/// `channel_success` inline (the request routed; the exit chunk /// `channel_success` inline (the request routed; the exit chunk
/// reports the serving outcome later). /// reports the serving outcome later).
@@ -1637,7 +1637,7 @@ fn pipe_row_dispatch(
} }
/// Installs the per-channel bridge surface, clones the session /// Installs the per-channel bridge surface, clones the session
/// handle, and spawns the spawned serving path — the session rows' /// handle, and spawns the serving path, the session rows'
/// spawn step (ADR-006 decision 4: the handler returns fast; the /// spawn step (ADR-006 decision 4: the handler returns fast; the
/// routed request answers `channel_success` inline). /// routed request answers `channel_success` inline).
#[cfg(feature = "session")] #[cfg(feature = "session")]
@@ -1674,24 +1674,24 @@ fn spawn_tty_row(
} }
/// The direct tunnel rows' dispatch (`w3-tunnel-direct`): the /// The direct tunnel rows' dispatch (`w3-tunnel-direct`): the
/// pre-admit decline classes first — **decline first, alloc never** /// pre-admit decline classes first, because **decline first, alloc
/// applies to the cap budget too, so every consumer is checked before /// never** applies to the cap budget too, so every consumer is checked before
/// the admission is taken. Order (the recorded admission mechanics: /// the admission is taken. Order (the recorded admission mechanics:
/// the admission is taken at the hook — the packet-loop's single /// the admission is taken at the hook, the packet-loop's single
/// admit-writer site, no race window against the cap check — and held /// admit-writer site, no race window against the cap check, and held
/// across the fabric open): /// across the fabric open):
/// ///
/// 1. **The unset fabric handle decline** (`seam-unconfigured`, the /// 1. **The unset fabric handle decline** (`seam-unconfigured`, the
/// unset-declines posture — the feature alone configures nothing): /// unset-declines posture, the feature alone configures nothing):
/// `ConnectFailed` ([`crate::dispatch::connect_failed_open_decline`]) /// `ConnectFailed` ([`crate::dispatch::connect_failed_open_decline`])
/// — the bridge cannot be attempted at all. /// as the bridge cannot be attempted at all.
/// 2. **The `no-identity` guard** — unreachable on the wire (russh /// 2. **The `no-identity` guard**: unreachable on the wire (russh
/// authorizes before channels exist) and fail-closed: never /// authorizes before channels exist) and fail-closed: never
/// anonymous serving (the git/tty rows' shape, discharged here for /// anonymous serving (the git/tty rows' shape, discharged here for
/// the tunnel row — the parked `w2-auth-bridge` item; the /// the tunnel row, the parked `w2-auth-bridge` item; the
/// `set_identity` mechanics the rides records live in /// `set_identity` mechanics the rides records live in
/// [`crate::tunnel_map`]). /// [`crate::tunnel_map`]).
/// 3. **The impossible-target decline** (`invalid-target` — an /// 3. **The impossible-target decline** (`invalid-target`, an
/// out-of-range wire port): `ConnectFailed`. /// out-of-range wire port): `ConnectFailed`.
/// ///
/// Then the cap admission ([`Self::admit_channel_open`]; beyond it → /// Then the cap admission ([`Self::admit_channel_open`]; beyond it →
+15 -15
View File
@@ -1,27 +1,27 @@
//! The client-side host-key policy seam (OQ-05 resolution) — the //! The client-side host-key policy seam (OQ-05 resolution): the
//! trust-decision contract the dial surface calls at russh's //! trust-decision contract the dial surface calls at russh's
//! `check_server_key` hook. //! `check_server_key` hook.
//! //!
//! Two cases, one seam — the crate is **policy-blind** //! Two cases, one seam: the crate is **policy-blind**
//! (`docs/architecture/auth.md` §Host-key policy): //! (`docs/architecture/auth.md` §Host-key policy):
//! //!
//! 1. **Known beforehand** (trust anchors exist; family-built runners //! 1. **Known beforehand** (trust anchors exist; family-built runners
//! mint their host key at assembly time): assembly-supplied pins //! mint their host key at assembly time): assembly-supplied pins
//! with **hard decline on mismatch**. The crate never hard-codes //! with **hard decline on mismatch**. The crate never hard-codes
//! this — a pin policy is a [`HostKeyPolicy`] implementation the //! this; a pin policy is a [`HostKeyPolicy`] implementation the
//! assembly writes, backed by its own pin store (the host *pin* is //! assembly writes, backed by its own pin store (the host *pin* is
//! public config, ADR-005 decision 6; never vault material). //! public config, ADR-005 decision 6; never vault material).
//! 2. **Unknown beforehand** (vanilla `sshd` on rental fleets; no //! 2. **Unknown beforehand** (vanilla `sshd` on rental fleets; no
//! anchor until first contact): prompt-and-record, //! anchor until first contact): prompt-and-record,
//! provider-metadata match, learn-and-record, or accept-and- //! provider-metadata match, learn-and-record, or accept-and-
//! continue (the ephemeral-fleet shape) — the OpenSSH //! continue (the ephemeral-fleet shape); the OpenSSH
//! "add to known_hosts?" moment is assembly policy, implemented in //! "add to known_hosts?" moment is assembly policy, implemented in
//! its [`HostKeyPolicy`], never crate behavior. //! its [`HostKeyPolicy`], never crate behavior.
//! //!
//! Crate rules: //! Crate rules:
//! //!
//! - The built-in default policy is **fail-closed**: [`RejectAll`] //! - The built-in default policy is **fail-closed**: [`RejectAll`]
//! rejects every presented key — russh's own reject-all default, //! rejects every presented key, russh's own reject-all default,
//! kept. There is **no accept-all built-in crate option**: a //! kept. There is **no accept-all built-in crate option**: a
//! permissive policy is the assembly's to write when its fleet //! permissive policy is the assembly's to write when its fleet
//! model calls for one. //! model calls for one.
@@ -32,11 +32,11 @@
//! Result<bool, Self::Error>`): the bool *is* the trust decision, //! Result<bool, Self::Error>`): the bool *is* the trust decision,
//! and the handler's `Err` channel stays wire/IO-failure territory. //! and the handler's `Err` channel stays wire/IO-failure territory.
//! A decline is a disposition, not an error (the crate's //! A decline is a disposition, not an error (the crate's
//! error-model contract) — decline reasons are the policy's own //! error-model contract); decline reasons are the policy's own
//! record, not a typed crate surface. //! record, not a typed crate surface.
//! - The [`PresentedHostKey`]'s fingerprint string is the contract- //! - The [`PresentedHostKey`]'s fingerprint string is the contract-
//! invariant format of the crate's single fingerprint path //! invariant format of the crate's single fingerprint path
//! ([`crate::fingerprint`]) — the wave-4 dial wiring computes it //! ([`crate::fingerprint`]); the wave-4 dial wiring computes it
//! there and surfaces it here. //! there and surfaces it here.
//! //!
//! This module is the seam and its default only: the russh //! This module is the seam and its default only: the russh
@@ -45,17 +45,17 @@
use async_trait::async_trait; use async_trait::async_trait;
/// The presented host key — the decision input to /// The presented host key, the decision input to
/// [`HostKeyPolicy::decide`]: the endpoint the dial contacted and the /// [`HostKeyPolicy::decide`]: the endpoint the dial contacted and the
/// key that endpoint presented. /// key that endpoint presented.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct PresentedHostKey { pub struct PresentedHostKey {
/// The hostname (or IP) the dial targeted — what the assembly's /// The hostname (or IP) the dial targeted, which the assembly's
/// pin store or prompt labels the key against. /// pin store or prompt labels the key against.
pub host: String, pub host: String,
/// The TCP port the dial targeted. /// The TCP port the dial targeted.
pub port: u16, pub port: u16,
/// The presented key's fingerprint — the single fingerprint /// The presented key's fingerprint, in the single fingerprint
/// path's `SHA256:` format ([`crate::fingerprint`]); comparison /// path's `SHA256:` format ([`crate::fingerprint`]); comparison
/// against assembly pins or recorded entries is exact string /// against assembly pins or recorded entries is exact string
/// equality by contract. /// equality by contract.
@@ -65,13 +65,13 @@ pub struct PresentedHostKey {
/// The trust-decision contract the dial surface calls (OQ-05): the /// The trust-decision contract the dial surface calls (OQ-05): the
/// crate surfaces the presented host key (host, port, fingerprint) to /// crate surfaces the presented host key (host, port, fingerprint) to
/// the assembly-provided policy, which decides accept or reject. The /// the assembly-provided policy, which decides accept or reject. The
/// crate is policy-blind — it never decides trust itself and carries /// crate is policy-blind: it never decides trust itself and carries
/// no trust model (see the module docs for the two-case model). /// no trust model (see the module docs for the two-case model).
/// ///
/// The handle shape is the family's injected-handle pattern /// The handle shape is the family's injected-handle pattern
/// (`Arc<dyn HostKeyPolicy>`, the alkcall `IdentityProvider` /// (`Arc<dyn HostKeyPolicy>`, the alkcall `IdentityProvider`
/// precedent): object-safe via `async_trait`, shared by /// precedent): object-safe via `async_trait`, shared by
/// reference — learn-and-record policies hold their state by interior /// reference; learn-and-record policies hold their state by interior
/// mutability so the same handle serves the dial and any inspection. /// mutability so the same handle serves the dial and any inspection.
/// ///
/// **Fail-closed posture**: when a policy cannot decide (its pin /// **Fail-closed posture**: when a policy cannot decide (its pin
@@ -81,19 +81,19 @@ pub struct PresentedHostKey {
#[async_trait] #[async_trait]
pub trait HostKeyPolicy: Send + Sync + 'static { pub trait HostKeyPolicy: Send + Sync + 'static {
/// Decides the trust verdict for one presented host key: `true` /// Decides the trust verdict for one presented host key: `true`
/// accepts the key for this dial, `false` declines it — the dial /// accepts the key for this dial, `false` declines it; the dial
/// connection aborts on decline (russh's `check_server_key` /// connection aborts on decline (russh's `check_server_key`
/// `false` → `Error::UnknownKey`, a hard, clean decline; a /// `false` → `Error::UnknownKey`, a hard, clean decline; a
/// mismatched pin is a hard decline, never a soft failure). /// mismatched pin is a hard decline, never a soft failure).
async fn decide(&self, presented: &PresentedHostKey) -> bool; async fn decide(&self, presented: &PresentedHostKey) -> bool;
} }
/// The built-in default host-key policy — **fail-closed**: reject /// The built-in default host-key policy, **fail-closed**: reject
/// every presented key. /// every presented key.
/// ///
/// russh's own reject-all default, kept as the crate's built-in /// russh's own reject-all default, kept as the crate's built-in
/// default policy (`docs/architecture/auth.md` §Host-key policy). A /// default policy (`docs/architecture/auth.md` §Host-key policy). A
/// dial that ships this policy can never connect — by design: trust /// dial that ships this policy can never connect, by design: trust
/// is the assembly's decision to make, and the crate's default must /// is the assembly's decision to make, and the crate's default must
/// never let a dial through on the crate's authority. There is no /// never let a dial through on the crate's authority. There is no
/// accept-all counterpart in the crate; the permissive case-2 policy /// accept-all counterpart in the crate; the permissive case-2 policy
+47 -46
View File
@@ -1,4 +1,4 @@
//! The vault-backed key sources — ADR-005's custody model in code, //! The vault-backed key sources: ADR-005's custody model in code,
//! both directions (client dial keys, server host keys). //! both directions (client dial keys, server host keys).
//! //!
//! The adapter takes *key sources*, not static keys (the alkhttp //! The adapter takes *key sources*, not static keys (the alkhttp
@@ -7,26 +7,24 @@
//! implements them over an injected alkvault handle. The crate ships //! implements them over an injected alkvault handle. The crate ships
//! the vault-backed implementations for the ADR-005 shape //! the vault-backed implementations for the ADR-005 shape
//! ([`VaultKeySource`] for one dial key, [`VaultHostKeySource`] for //! ([`VaultKeySource`] for one dial key, [`VaultHostKeySource`] for
//! the host-key set) along with the decrypt-at-use chain they run — //! the host-key set) along with the decrypt-at-use chain they run:
//! the custody machinery is the crate's, the at-rest storage is the //! the custody machinery is the crate's, the at-rest storage is the
//! assembly's. //! assembly's.
//! //!
//! Crate rules, per ADR-005: //! Crate rules, per ADR-005:
//! //!
//! - **`EncryptedData` is opaque** (decision 5): blobs flow through //! - **`EncryptedData` is opaque** (decision 5): blobs flow through
//! this module to `VaultServiceHandle::decrypt` untouched — no //! this module to `VaultServiceHandle::decrypt` untouched: no
//! `key_version` interpretation, no record-internals access //! `key_version` interpretation, no record-internals access
//! anywhere in the crate; version semantics belong to the vault. //! anywhere in the crate; version semantics belong to the vault.
//! - **At-use lifecycle** (decision 2): `vault.decrypt(blob)` → //! - **At-use lifecycle** (decision 2): `vault.decrypt(blob)` →
//! `Zeroizing<String>` wrap at the adapter seam (decision 3 — the //! the at-use chain's single-use read → one-time
//! known-gap contract; the vault-side improvement is upstream, not
//! here) → one-time
//! [`decode_secret_key`](russh::keys::decode_secret_key) → the //! [`decode_secret_key`](russh::keys::decode_secret_key) → the
//! decoded `PrivateKey` lives for the dial's lifetime. The vault is //! decoded `PrivateKey` lives for the dial's lifetime. The vault is
//! needed only at use time: locking the vault mid-session keeps //! needed only at use time: locking the vault mid-session keeps
//! live sessions streaming while new loads fail (POC-2 §2). //! live sessions streaming while new loads fail (POC-2 §2).
//! - **Inner format is OpenSSH private-key PEM, unencrypted** //! - **Inner format is OpenSSH private-key PEM, unencrypted**
//! (decision 1) — the source contract assumes it; the decode call //! (decision 1), which the source contract assumes; the decode call
//! is the single conversion. A plaintext that is not that shape is //! is the single conversion. A plaintext that is not that shape is
//! a [`KeySourceError::Decode`] refusal, not a fallback. //! a [`KeySourceError::Decode`] refusal, not a fallback.
//! - **Failure modes are fail-closed with distinct variants** //! - **Failure modes are fail-closed with distinct variants**
@@ -37,7 +35,7 @@
//! - **Plaintext never persists** (decision 7): no disk spill on this //! - **Plaintext never persists** (decision 7): no disk spill on this
//! path, ever. //! path, ever.
//! - Vault lifecycle is the assembly's: this module never unlocks //! - Vault lifecycle is the assembly's: this module never unlocks
//! the vault and never retries — a locked vault stays a refusal. //! the vault and never retries; a locked vault stays a refusal.
use alkvault::{EncryptedData, VaultServiceError, VaultServiceHandle}; use alkvault::{EncryptedData, VaultServiceError, VaultServiceHandle};
use async_trait::async_trait; use async_trait::async_trait;
@@ -46,7 +44,7 @@ use zeroize::Zeroizing;
use crate::error::KeySourceError; use crate::error::KeySourceError;
/// The client dial-key source seam — the handle the wave-4 dial-config /// The client dial-key source seam, the handle the wave-4 dial-config
/// carries (never a static key, never env material). /// carries (never a static key, never env material).
/// ///
/// Produces the decoded [`PrivateKey`] for one dial, decrypt-at-use: /// Produces the decoded [`PrivateKey`] for one dial, decrypt-at-use:
@@ -54,12 +52,12 @@ use crate::error::KeySourceError;
/// key outlives any vault state change (locking mid-session keeps /// key outlives any vault state change (locking mid-session keeps
/// established sessions streaming; POC-2 §2). Implementations must be /// established sessions streaming; POC-2 §2). Implementations must be
/// fail-closed: a load that cannot produce key material returns the /// fail-closed: a load that cannot produce key material returns the
/// matching [`KeySourceError`] variant — every variant is a /// matching [`KeySourceError`] variant; every variant is a
/// no-dial outcome, and there is no fallback key shape in the crate. /// no-dial outcome, and there is no fallback key shape in the crate.
/// ///
/// The handle shape is the family's injected-handle pattern /// The handle shape is the family's injected-handle pattern
/// (`Arc<dyn KeySource>`, the alkcall `IdentityProvider` precedent): /// (`Arc<dyn KeySource>`, the alkcall `IdentityProvider` precedent):
/// object-safe via `async_trait`, shared by reference — the vault /// object-safe via `async_trait`, shared by reference; the vault
/// handle rides the implementation (the assembly clones its /// handle rides the implementation (the assembly clones its
/// [`VaultServiceHandle`] into its source). The crate's ADR-005 /// [`VaultServiceHandle`] into its source). The crate's ADR-005
/// implementation is [`VaultKeySource`]; an assembly may implement the /// implementation is [`VaultKeySource`]; an assembly may implement the
@@ -67,48 +65,48 @@ use crate::error::KeySourceError;
/// contract (OpenSSH-PEM, unencrypted). /// contract (OpenSSH-PEM, unencrypted).
#[async_trait] #[async_trait]
pub trait KeySource: Send + Sync + 'static { pub trait KeySource: Send + Sync + 'static {
/// Loads the dial key — decryption plus the one-time /// Loads the dial key: decryption plus the one-time
/// `decode_secret_key` (ADR-005 decision 2). /// `decode_secret_key` (ADR-005 decision 2).
/// ///
/// This is the only surface where the dial direction touches /// This is the only surface where the dial direction touches
/// secret material: the plaintext exists solely inside this /// secret material: the plaintext exists solely inside this
/// method and is dropped (zeroized from the adapter seam onward) /// method and is zeroized by the vault's `Zeroizing<String>` return
/// before the decoded key is returned. /// at its drop, before the decoded key is returned.
async fn load(&self) -> Result<PrivateKey, KeySourceError>; async fn load(&self) -> Result<PrivateKey, KeySourceError>;
} }
/// The server host-key source seam — the handle the wave-2 /// The server host-key source seam, the handle the wave-2
/// serve-config carries (ADR-005 decision 6). /// serve-config carries (ADR-005 decision 6).
/// ///
/// Produces the decoded host-key set for server construction — /// Produces the decoded host-key set for server construction,
/// decrypt-**at-construction**: the wave-2 `Config` builder calls /// decrypt-**at-construction**: the wave-2 `Config` builder calls
/// [`HostKeySource::load`] when it assembles the server, and a failure /// [`HostKeySource::load`] when it assembles the server, and a failure
/// means no server is constructed (POC-2 §5; locked vault at /// means no server is constructed (POC-2 §5; locked vault at
/// bootstrap → [`KeySourceError::Locked`] → fail-closed, never a /// bootstrap → [`KeySourceError::Locked`] → fail-closed, never a
/// half-configured server). The crate is policy-blind on host *pin* /// half-configured server). The crate is policy-blind on host *pin*
/// trust — that is the [`crate::HostKeyPolicy`] seam's business — this /// trust (that is the [`crate::HostKeyPolicy`] seam's business); this
/// seam is only the *held* material (the client's host pin is public /// seam is only the *held* material (the client's host pin is public
/// config, never vault material). /// config, never vault material).
/// ///
/// The set, not a single key: `Config::keys` accepts multiple /// The set, not a single key: `Config::keys` accepts multiple
/// host keys and fleets rotate them. Implementations must be /// host keys and fleets rotate them. Implementations must be
/// fail-closed in the same shape as [`KeySource`] — see the trait's /// fail-closed in the same shape as [`KeySource`]; see the trait's
/// docs. The crate's ADR-005 implementation is [`VaultHostKeySource`]. /// docs. The crate's ADR-005 implementation is [`VaultHostKeySource`].
#[async_trait] #[async_trait]
pub trait HostKeySource: Send + Sync + 'static { pub trait HostKeySource: Send + Sync + 'static {
/// Loads the host-key set — each blob through the same /// Loads the host-key set, each blob through the same
/// decrypt-at-use chain as [`KeySource::load`]. Order follows the /// decrypt-at-use chain as [`KeySource::load`]. Order follows the
/// source's registration order. /// source's registration order.
async fn load(&self) -> Result<Vec<PrivateKey>, KeySourceError>; async fn load(&self) -> Result<Vec<PrivateKey>, KeySourceError>;
} }
/// The ADR-005 dial-key source — decrypt-at-use over an injected /// The ADR-005 dial-key source, decrypt-at-use over an injected
/// alkvault handle, backed by one at-rest `EncryptedData` record. /// alkvault handle, backed by one at-rest `EncryptedData` record.
/// ///
/// The record is injected at construction, from the assembly's /// The record is injected at construction, from the assembly's
/// registry (`None` = the assembly holds no record at this key's /// registry (`None` = the assembly holds no record at this key's
/// location → [`KeySourceError::Absent`], POC-2 §3). The vault handle /// location → [`KeySourceError::Absent`], POC-2 §3). The vault handle
/// is a clone of the assembly's — this source never unlocks, locks, or /// is a clone of the assembly's; this source never unlocks, locks, or
/// retries the vault's lifecycle state; every [`VaultKeySource::load`] /// retries the vault's lifecycle state; every [`VaultKeySource::load`]
/// is decrypt-at-usage and a locked vault is a clean /// is decrypt-at-usage and a locked vault is a clean
/// [`KeySourceError::Locked`] refusal. /// [`KeySourceError::Locked`] refusal.
@@ -136,11 +134,11 @@ impl KeySource for VaultKeySource {
} }
} }
/// The ADR-005 host-key source — decrypt-at-construction over an /// The ADR-005 host-key source, decrypt-at-construction over an
/// injected alkvault handle, backed by the injected at-rest record set. /// injected alkvault handle, backed by the injected at-rest record set.
/// ///
/// Each element of the set flows through the same decrypt-and-decode /// Each element of the set flows through the same decrypt-and-decode
/// chain as [`VaultKeySource`] (one model, both directions — /// chain as [`VaultKeySource`] (one model, both directions;
/// POC-2 §5). An empty set is [`KeySourceError::Absent`] (a host key /// POC-2 §5). An empty set is [`KeySourceError::Absent`] (a host key
/// registration gap), which the wave-2 config builder treats exactly /// registration gap), which the wave-2 config builder treats exactly
/// like every other load failure: no server is constructed. /// like every other load failure: no server is constructed.
@@ -171,45 +169,48 @@ impl HostKeySource for VaultHostKeySource {
} }
} }
/// The ADR-005 at-use chain (decision 2): one vault decrypt, the /// The ADR-005 at-use chain (decision 2): one vault decrypt, one
/// `Zeroizing<String>` seam wrap, one `decode_secret_key`. /// `decode_secret_key`.
/// ///
/// The plaintext `String` leaves the vault as /// The plaintext leaves the vault as
/// [`KeySourceError`]-mapped failure or enters the wrap immediately — /// [`KeySourceError`]-mapped failure or as alkvault 0.2's
/// its only unfenced window is the vault's own `String` return, the /// `Zeroizing<String>` return (the pre-wrap unfenced window ADR-005
/// known-gap contract of ADR-005 decision 3 (the upstream ask is /// decision 3 recorded is closed upstream: the vault fences the
/// alkvault's; OQ-07). The decode is the single inner-format /// plaintext from its return onward, zeroized on drop, including the
/// conversion (decision 1); a plaintext that is not OpenSSH-PEM key /// vault's own UTF-8 failure path; OQ-07's upstream ask is resolved,
/// material is a [`KeySourceError::Decode`] refusal. /// amending the ADR's decision 3 note). The decode is the single
/// inner-format conversion (decision 1); a plaintext that is not
/// OpenSSH-PEM key material is a [`KeySourceError::Decode`] refusal.
fn decrypt_and_decode( fn decrypt_and_decode(
vault: &VaultServiceHandle, vault: &VaultServiceHandle,
blob: &EncryptedData, blob: &EncryptedData,
) -> Result<PrivateKey, KeySourceError> { ) -> Result<PrivateKey, KeySourceError> {
let plaintext = Zeroizing::new(vault.decrypt(blob).map_err(map_vault_error)?); let plaintext = vault.decrypt(blob).map_err(map_vault_error)?;
decode_key_arm(&plaintext) decode_key_arm(&plaintext)
} }
/// The at-use chain's decode arm — the one-time /// The at-use chain's decode arm: the one-time
/// [`decode_secret_key`] conversion of [`decrypt_and_decode`] /// [`decode_secret_key`] conversion of [`decrypt_and_decode`]
/// (ADR-005 decision 2's single conversion; this function is byte-for- /// (ADR-005 decision 2's single conversion; this function is byte-for-
/// byte that arm, extracted so the arm is the *only* shape the fuzz /// byte that arm, extracted so the arm is the *only* shape the fuzz
/// target drives). `#[doc(hidden)]` fuzz-visible surface /// target drives). `#[doc(hidden)]` fuzz-visible surface
/// (`w5-fuzz-harness`; the recorded exposure decision — the ingress is /// (`w5-fuzz-harness`; the recorded exposure decision: the ingress is
/// production-private, so the exposure is the production arm itself, /// production-private, so the exposure is the production arm itself,
/// not a fork). Not documented API: the vault-facing surface is /// not a fork). Not documented API: the vault-facing surface is
/// exactly [`VaultKeySource`] / [`VaultHostKeySource`]. /// exactly [`VaultKeySource`] / [`VaultHostKeySource`].
/// ///
/// The `Zeroizing` posture rides the signature: the plaintext arrives /// The `Zeroizing` posture rides the signature: the plaintext
/// already wrapped at the adapter seam (decision 3), the decode reads /// arrives already zeroized (alkvault 0.2's return type, decision
/// it once, and nothing is retained — `Ok` carries the decoded /// 3's ownership moved upstream), the decode reads
/// it once, and nothing is retained: `Ok` carries the decoded
/// [`PrivateKey`], `Err` carries only the typed [`KeySourceError`] /// [`PrivateKey`], `Err` carries only the typed [`KeySourceError`]
/// class (never the plaintext). /// class (never the plaintext).
/// ///
/// The typed `Decode` payload is crate-owned fixed text, deliberately /// The typed `Decode` payload is crate-owned fixed text, deliberately
/// *not* the underlying machinery's message (the `w5-fuzz-harness` /// *not* the underlying machinery's message (the `w5-fuzz-harness`
/// finding, 2026-10-30): russh's/`ssh-key`'s PPK parse path /// finding, 2026-10-30): russh's/`ssh-key`'s PPK parse path
/// (`PpkParseError::Header`) interpolates raw header bytes — the /// (`PpkParseError::Header`) interpolates raw header bytes, the
/// plaintext's own material — into its Display, so a machinery message /// plaintext's own material, into its Display, so a machinery message
/// riding the typed payload would echo decrypted-record material /// riding the typed payload would echo decrypted-record material
/// exactly the no-persist posture (decision 7) forbids. The upstream /// exactly the no-persist posture (decision 7) forbids. The upstream
/// echo ask is recorded with the task; if the machinery gains an /// echo ask is recorded with the task; if the machinery gains an
@@ -218,7 +219,7 @@ fn decrypt_and_decode(
pub fn decode_key_arm(plaintext: &Zeroizing<String>) -> Result<PrivateKey, KeySourceError> { pub fn decode_key_arm(plaintext: &Zeroizing<String>) -> Result<PrivateKey, KeySourceError> {
decode_secret_key(plaintext, None).map_err(|_| { decode_secret_key(plaintext, None).map_err(|_| {
KeySourceError::Decode( KeySourceError::Decode(
"the plaintext is not OpenSSH private-key PEM material — the decode machinery \ "the plaintext is not OpenSSH private-key PEM material; the decode machinery \
refused it (the machinery's message is not carried: it can echo plaintext \ refused it (the machinery's message is not carried: it can echo plaintext \
material; the upstream echo ask is recorded)" material; the upstream echo ask is recorded)"
.to_owned(), .to_owned(),
@@ -227,18 +228,18 @@ pub fn decode_key_arm(plaintext: &Zeroizing<String>) -> Result<PrivateKey, KeySo
} }
/// The vault's error taxonomy reshaped into ADR-005 decision 4's /// The vault's error taxonomy reshaped into ADR-005 decision 4's
/// five-variant matrix — never re-exported, never collapsed into one. /// five-variant matrix, never re-exported, never collapsed into one.
/// ///
/// `VaultLocked` → [`KeySourceError::Locked`]; `InvalidPath` (the /// `VaultLocked` → [`KeySourceError::Locked`]; `InvalidPath` (the
/// vault's refuse-before-crypto refusal of an unresolvable derivation /// vault's refuse-before-crypto refusal of an unresolvable derivation
/// path — reserved/legacy v1, v0) → /// path, such as reserved/legacy v1 or v0) →
/// [`KeySourceError::UnknownKeyVersion`]; `Encryption` (the GCM-open /// [`KeySourceError::UnknownKeyVersion`]; `Encryption` (the GCM-open
/// class: wrong mnemonic, tampered ciphertext) is honest refuted /// class: wrong mnemonic, tampered ciphertext) is honest refuted
/// decryption → [`KeySourceError::Decryption`]. The remaining vault /// decryption → [`KeySourceError::Decryption`]. The remaining vault
/// variants cannot arise on the decrypt path (decryption never /// variants cannot arise on the decrypt path (decryption never
/// touches mnemonic parsing, key derivation outside version-path /// touches mnemonic parsing, key derivation outside version-path
/// resolution, unlock state transitions, or key-type registration; /// resolution, unlock state transitions, or key-type registration;
/// POC-2 §3) — if one were ever observed it would be a vault-side /// POC-2 §3); if one were ever observed it would be a vault-side
/// refuse-to-decrypt condition, so it lands in the `Decryption` class /// refuse-to-decrypt condition, so it lands in the `Decryption` class
/// with the vault's message intact rather than silently succeeding. /// with the vault's message intact rather than silently succeeding.
fn map_vault_error(error: VaultServiceError) -> KeySourceError { fn map_vault_error(error: VaultServiceError) -> KeySourceError {
@@ -272,14 +273,14 @@ mod tests {
use crate::error::KeySourceError; use crate::error::KeySourceError;
use crate::fingerprint::fingerprint; use crate::fingerprint::fingerprint;
/// The committed test-key fixtures (`tests/vectors/`) — the /// The committed test-key fixtures (`tests/vectors/`), the
/// wave-1 fixture set, read by this `#[cfg(test)]` module through /// wave-1 fixture set, read by this `#[cfg(test)]` module through
/// `include_str!` (the `tests/common/mod.rs` double module is the /// `include_str!` (the `tests/common/mod.rs` double module is the
/// tests/-side owner; src-unit tests never own fixture copies). /// tests/-side owner; src-unit tests never own fixture copies).
/// Throwaway `ssh-keygen`-minted keys, never registered anywhere /// Throwaway `ssh-keygen`-minted keys, never registered anywhere
/// real (`ALPHA` is the client-dial fixture; `BRAVO`/`CHARLIE` /// real (`ALPHA` is the client-dial fixture; `BRAVO`/`CHARLIE`
/// the host-key pair). Their `ssh-keygen -lf` fingerprints are /// the host-key pair). Their `ssh-keygen -lf` fingerprints are
/// recorded in `tests/vectors/fingerprints.txt` — the fixture /// recorded in `tests/vectors/fingerprints.txt`; the fixture
/// parity row below asserts this module sees the same values. /// parity row below asserts this module sees the same values.
const ALPHA_PEM: &str = include_str!("../tests/vectors/alpha.key"); const ALPHA_PEM: &str = include_str!("../tests/vectors/alpha.key");
const BRAVO_PEM: &str = include_str!("../tests/vectors/bravo.key"); const BRAVO_PEM: &str = include_str!("../tests/vectors/bravo.key");
+33 -33
View File
@@ -1,61 +1,61 @@
//! alkssh — the alk family's SSH adapter (the alkhttp-style door for the //! alkssh, the alk family's SSH adapter (the alkhttp-style door for the
//! SSH protocol): a thin wrapper around russh terminating the vanilla-ssh //! SSH protocol): a thin wrapper around russh terminating the vanilla-ssh
//! wire and bridging to/from alkcall channels (the `alk/ssh` ALPN). //! wire and bridging to/from alkcall channels (the `alk/ssh` ALPN).
//! //!
//! No SSH framing is authored in-family (family ADR-003): russh owns the //! No SSH framing is authored in-family (family ADR-003): russh owns the
//! wire, and the crate's substance is the channel-type mappings — //! wire, and the crate's substance is the channel-type mappings:
//! session/pty → alktty, direct/forwarded-tcpip → alktunnels, git exec → //! session/pty → alktty, direct/forwarded-tcpip → alktunnels, git exec →
//! alkgit, sftp → alkfs later (a declining placeholder until then), each //! alkgit, sftp → alkfs later (a declining placeholder until then), each
//! behind its cargo feature. Git over SSH is exec-only (ADR-006): a //! behind its cargo feature. Git over SSH is exec-only (ADR-006): a
//! subsystem request can never carry the repository, so it declines. A //! subsystem request can never carry the repository, so it declines. A
//! client opening an unfeatured or unimplemented channel type gets a //! client opening an unfeatured or unimplemented channel type gets a
//! clean decline — never a half-capability, never a silent fallback; the //! clean decline, never a half-capability, never a silent fallback; the
//! default build (no features) is structurally incapable of serving //! default build (no features) is structurally incapable of serving
//! anything but declines. //! anything but declines.
//! //!
//! Identity is the family's, not SSH's: a verified key's fingerprint → //! Identity is the family's, not SSH's: a verified key's fingerprint →
//! alkcall `IdentityProvider`. Keys the crate holds (client dial keys, //! alkcall `IdentityProvider`. Keys the crate holds (client dial keys,
//! host keys) come from alkvault-backed sources — vault-encrypted at //! host keys) come from alkvault-backed sources, vault-encrypted at
//! rest, decrypted at use (ADR-005), never env vars. //! rest, decrypted at use (ADR-005), never env vars.
//! //!
//! Architecture: `docs/architecture/` (phase 1 — ADRs 001–006 accepted; //! Architecture: `docs/architecture/` (phase 1: ADRs 001–006 accepted;
//! the foundations, the server stack, the server-direction mappings, and //! the foundations, the server stack, the server-direction mappings, and
//! the client direction — dial, op rows, the `-R` acceptor leg, and the //! the client direction (dial, op rows, the `-R` acceptor leg, and the
//! lifecycle primitives — are landed through wave 4; the validation //! lifecycle primitives) are landed through wave 4; the validation
//! suite and the release are wave 5). See also `docs/research/`, //! suite and the release are wave 5). See also `docs/research/`,
//! `docs/plans/implementation.md`, and family ADR-003. //! `docs/plans/implementation.md`, and family ADR-003.
//! //!
//! # The feature graph (ADR-003 — flat, independent, fail-closed) //! # The feature graph (ADR-003: flat, independent, fail-closed)
//! //!
//! Every channel-type mapping is a cargo feature; a session opening an //! Every channel-type mapping is a cargo feature; a session opening an
//! unfeatured or unimplemented channel type is declined fail-closed — //! unfeatured or unimplemented channel type is declined fail-closed,
//! never a half-capability, never a silent fallback. **A default build //! never a half-capability, never a silent fallback. **A default build
//! (no features) has no capabilities**: it can serve nothing but clean //! (no features) has no capabilities**: it can serve nothing but clean
//! declines. No feature implies another; the graph is flat. //! declines. No feature implies another; the graph is flat.
//! //!
//! - (default, no features) — serves declines only: every open, exec, //! - (default, no features) serves declines only: every open, exec,
//! listen request answers a clean, channel-scoped refusal. //! listen request answers a clean, channel-scoped refusal.
//! - `session` — pty/shell/exec rows → the `RemoteSession` chunk //! - `session` maps the pty/shell/exec rows → the `RemoteSession` chunk
//! plane (alktty). //! plane (alktty).
//! - `git` — the exec-only git rows (`git-{upload,receive}-pack`) → //! - `git` carries the exec-only git rows (`git-{upload,receive}-pack`) →
//! alkgit; a git *subsystem* request always declines (the repository //! alkgit; a git *subsystem* request always declines (the repository
//! can never ride a subsystem channel). //! can never ride a subsystem channel).
//! - `tunnel` — `direct-tcpip` / `direct-streamlocal` and the `-R` //! - `tunnel` covers `direct-tcpip` / `direct-streamlocal` and the `-R`
//! legs (listen, `forwarded-tcpip` / `forwarded-streamlocal`) → //! legs (listen, `forwarded-tcpip` / `forwarded-streamlocal`) →
//! alktunnels. //! alktunnels.
//! - `sftp` — a reserved, currently-empty placeholder (OQ-13): the //! - `sftp`, a reserved, currently-empty placeholder (OQ-13): the
//! `sftp` subsystem request still declines in every build until the //! `sftp` subsystem request still declines in every build until the
//! alkfs mapping lands; the feature exists so the decline stays //! alkfs mapping lands; the feature exists so the decline stays
//! contract territory (tested in every matrix build). //! contract territory (tested in every matrix build).
//! //!
//! Advertisement is honest: an unfeatured channel type is never //! Advertisement is honest: an unfeatured channel type is never
//! advertised, only declined (`docs/architecture/mapping.md` §Decline //! advertised, only declined (`docs/architecture/mapping.md` §Decline
//! matrix — the fail-closed invariant is contract territory on both //! matrix; the fail-closed invariant is contract territory on both
//! sides of every feature). //! sides of every feature).
//! //!
//! # Known limits (recorded dispositions riding the published docs) //! # Known limits (recorded dispositions riding the published docs)
//! //!
//! The records below are deliberate dispositions — documented limits //! The records below are deliberate dispositions: documented limits
//! with their reasons, mirrored in the module docs and the //! with their reasons, mirrored in the module docs and the
//! architecture spec, not defects awaiting a fix: //! architecture spec, not defects awaiting a fix:
//! //!
@@ -64,29 +64,29 @@
//! per-channel window, the client's own flow control) and liveness //! per-channel window, the client's own flow control) and liveness
//! is covered by the always-set //! is covered by the always-set
//! [`Limits::inactivity_timeout`]; a deadline here would be a //! [`Limits::inactivity_timeout`]; a deadline here would be a
//! behavior change (a slow client may stall its stream — vanilla //! behavior change (a slow client may stall its stream, vanilla
//! sshd's posture included). (`tty_map.rs` module docs.) //! sshd's posture included). (`tty_map.rs` module docs.)
//! - **The PTY path's exit-signal fidelity is limited by upstream** //! - **The PTY path's exit-signal fidelity is limited by upstream**
//! (exit-currency rule 5): signal-terminated PTY sessions surface as //! (exit-currency rule 5): signal-terminated PTY sessions surface as
//! `exit-status(1)` because the backend's reap surface loses the //! `exit-status(1)` because the backend's reap surface loses the
//! signal number; the pipe path encodes negatives faithfully. An //! signal number; the pipe path encodes negatives faithfully. An
//! alktty/portable-pty ask — the contract adapts automatically //! alktty/portable-pty ask; the contract adapts automatically
//! when upstream conforms. //! when upstream conforms.
//! - **The port-0 mint is the only bound-port echo the wire carries** //! - **The port-0 mint is the only bound-port echo the wire carries**
//! (russh/sshd parity): a `tcpip_forward` success reply carries the //! (russh/sshd parity): a `tcpip_forward` success reply carries the
//! allocated port only for a `port = 0` request; an explicit-port //! allocated port only for a `port = 0` request; an explicit-port
//! request's bound port is by definition the requested one — the //! request's bound port is by definition the requested one; the
//! crate surfaces it as such, never a fabricated number. //! crate surfaces it as such, never a fabricated number.
//! - **No reconnect/restore policy.** The client direction ships //! - **No reconnect/restore policy.** The client direction ships
//! lifecycle *primitives* (bound ports, cancellable handles, clean //! lifecycle *primitives* (bound ports, cancellable handles, clean
//! [`SshConnection::close`]); reconnect-and-restore (rebinding //! [`SshConnection::close`]); reconnect-and-restore (rebinding
//! persisted tunnel ports) is assembly composition over them — the //! persisted tunnel ports) is assembly composition over them; the
//! crate never owns the policy. Drop is never the clean path at //! crate never owns the policy. Drop is never the clean path at
//! this russh pin: only an explicit `close` sends the clean //! this russh pin: only an explicit `close` sends the clean
//! disconnect. //! disconnect.
//! - **No accept-all host-key policy exists in the crate.** The //! - **No accept-all host-key policy exists in the crate.** The
//! built-in default is [`RejectAll`] (a dial with it can never //! built-in default is [`RejectAll`] (a dial with it can never
//! connect — by design); the crate is policy-blind and any //! connect, by design); the crate is policy-blind and any
//! permissive trust policy is the assembly's to write. A //! permissive trust policy is the assembly's to write. A
//! fail-closed policy posture: when a policy cannot decide, it must //! fail-closed policy posture: when a policy cannot decide, it must
//! decline. //! decline.
@@ -96,10 +96,10 @@
//! The crate is a door, not a deployment: an assembly embeds it, //! The crate is a door, not a deployment: an assembly embeds it,
//! enables the features it wants, and injects the seams (identity, //! enables the features it wants, and injects the seams (identity,
//! key sources, the per-feature family handles). The examples are //! key sources, the per-feature family handles). The examples are
//! compile-checked (`no_run`) doctests — honest sketches, not //! compile-checked (`no_run`) doctests, honest sketches, not
//! executable code. //! executable code.
//! //!
//! A server mount (the default build — a decline-only door; the //! A server mount (the default build, a decline-only door; the
//! assembly's real sources are the vault-backed [`VaultKeySource`] / //! assembly's real sources are the vault-backed [`VaultKeySource`] /
//! [`VaultHostKeySource`] shapes): //! [`VaultHostKeySource`] shapes):
//! //!
@@ -151,7 +151,7 @@
//! ``` //! ```
//! //!
//! A dial (the client direction; the built-in [`RejectAll`] //! A dial (the client direction; the built-in [`RejectAll`]
//! host-key policy connects to nothing — by design, the assembly's //! host-key policy connects to nothing, by design, and the assembly's
//! policy replaces it): //! policy replaces it):
//! //!
//! ```rust,no_run //! ```rust,no_run
@@ -182,7 +182,7 @@
//! # { //! # {
//! # let _ = conn.exec("uptime"); // the op rows (feature-gated) //! # let _ = conn.exec("uptime"); // the op rows (feature-gated)
//! # } //! # }
//! conn.close().await; // clean disconnect — drop is never the clean path //! conn.close().await; // clean disconnect; drop is never the clean path
//! # Ok::<(), alkssh::DialError>(()) //! # Ok::<(), alkssh::DialError>(())
//! # })?; //! # })?;
//! # Ok(()) //! # Ok(())
@@ -225,7 +225,7 @@ pub use client::dial;
pub use client::russh_client_config; pub use client::russh_client_config;
/// The `-R` leg's acceptor registry + the reverse-listen rows' /// The `-R` leg's acceptor registry + the reverse-listen rows'
/// payload/acceptor types + the listener handles (`w4-reverse-listen` /// payload/acceptor types + the listener handles (`w4-reverse-listen`
/// / `w4-lifecycle` — under `tunnel`; the recorded decisions live in /// / `w4-lifecycle`, under `tunnel`; the recorded decisions live in
/// the `client_accept` module docs). /// the `client_accept` module docs).
#[cfg(feature = "tunnel")] #[cfg(feature = "tunnel")]
pub use client_accept::{ pub use client_accept::{
@@ -235,14 +235,14 @@ pub use client_accept::{
#[doc(hidden)] #[doc(hidden)]
pub use client_handler::ClientHandler; pub use client_handler::ClientHandler;
/// The client op rows' surfaces (`w4-op-rows`): /// The client op rows' surfaces (`w4-op-rows`):
/// [`RemoteSession`] — the exec/pty rows' /// [`RemoteSession`]; the exec/pty rows'
/// family chunk plane (under `session`), /// family chunk plane (under `session`),
/// [`TunnelChannel`] — the direct /// [`TunnelChannel`]; the direct
/// rows' byte duplex (under `tunnel`), /// rows' byte duplex (under `tunnel`),
/// [`GitPackSession`] — the upstream git /// [`GitPackSession`]; the upstream git
/// row's pack duplex + consumed exit (under `git`). The op methods /// row's pack duplex + consumed exit (under `git`). The op methods
/// live on [`SshConnection`] (the op-row surface). Not a re-export /// live on [`SshConnection`] (the op-row surface). Not a re-export
/// here: the `sftp` row is [`SshConnection::sftp`] itself — the /// here: the `sftp` row is [`SshConnection::sftp`] itself, the
/// every-build OQ-13 decline placeholder. /// every-build OQ-13 decline placeholder.
#[cfg(feature = "git")] #[cfg(feature = "git")]
pub use client_ops::GitPackSession; pub use client_ops::GitPackSession;
@@ -258,7 +258,7 @@ pub use config::SessionSeam;
pub use config::TunnelListenSeam; pub use config::TunnelListenSeam;
pub use config::{DialConfig, Limits, ServeConfig}; pub use config::{DialConfig, Limits, ServeConfig};
/// Fuzz-visible surface (`#[doc(hidden)]`, `w5-fuzz-harness`): the exec /// Fuzz-visible surface (`#[doc(hidden)]`, `w5-fuzz-harness`): the exec
/// grammar's parse path (`dispatch.rs`) — the `fuzz/` sidecar's /// grammar's parse path (`dispatch.rs`); the `fuzz/` sidecar's
/// `exec_parse` target drives the production parse, never a fork. Not /// `exec_parse` target drives the production parse, never a fork. Not
/// documented API: the grammar is the dispatch seam's, not a public-API /// documented API: the grammar is the dispatch seam's, not a public-API
/// promise. /// promise.
@@ -267,7 +267,7 @@ pub use dispatch::{ExecDecline, ExecRoute, GitServiceRoute, parse_exec};
pub use error::{AdapterError, DialError, KeySourceError, OpError, OpenDeclineReason}; pub use error::{AdapterError, DialError, KeySourceError, OpError, OpenDeclineReason};
pub use fingerprint::fingerprint; pub use fingerprint::fingerprint;
/// Contract-test surface (`#[doc(hidden)]`): the per-connection auth /// Contract-test surface (`#[doc(hidden)]`): the per-connection auth
/// bridge handler — constructible so the tests drive its hooks directly /// bridge handler, constructible so the tests drive its hooks directly
/// against the wave-1 doubles (see `handler.rs` §Test surface). Not /// against the wave-1 doubles (see `handler.rs` §Test surface). Not
/// documented API: the wire-facing surface is exactly `SshAdapter` + /// documented API: the wire-facing surface is exactly `SshAdapter` +
/// the config/error/host/key-source types. /// the config/error/host/key-source types.
@@ -281,7 +281,7 @@ pub use host_key::{HostKeyPolicy, PresentedHostKey, RejectAll};
pub use key_source::decode_key_arm; pub use key_source::decode_key_arm;
pub use key_source::{HostKeySource, KeySource, VaultHostKeySource, VaultKeySource}; pub use key_source::{HostKeySource, KeySource, VaultHostKeySource, VaultKeySource};
/// Contract-test surface (`#[doc(hidden)]`): the spawn-and-return /// Contract-test surface (`#[doc(hidden)]`): the spawn-and-return
/// plumbing (`ADR-006 decision 4`, see `serve.rs`) — the mechanics the /// plumbing (`ADR-006 decision 4`, see `serve.rs`), the mechanics the
/// mechanics-proof rows in `tests/spawn_return.rs` compose over the /// mechanics-proof rows in `tests/spawn_return.rs` compose over the
/// real wire to pin the constraint as tests. Not documented API. /// real wire to pin the constraint as tests. Not documented API.
#[doc(hidden)] #[doc(hidden)]
+57 -57
View File
@@ -1,5 +1,5 @@
//! The decline-path + serving-path event vocabulary //! The decline-path + serving-path event vocabulary
//! (`w3-decline-tracing` — the wave-2 gate's booked finding): the //! (`w3-decline-tracing`, the wave-2 gate's booked finding): the
//! observability surface every decline site in `src/` emits through, //! observability surface every decline site in `src/` emits through,
//! and the serving-path lifecycle skeleton ([`serving_path_started`] / //! and the serving-path lifecycle skeleton ([`serving_path_started`] /
//! [`serving_path_ended`] / [`serving_path_failed`]) the wave-3 //! [`serving_path_ended`] / [`serving_path_failed`]) the wave-3
@@ -7,7 +7,7 @@
//! //!
//! ## The family posture read (2026-10-28) //! ## The family posture read (2026-10-28)
//! //!
//! Read before choosing this shape — no alkssh-private dialect: //! Read before choosing this shape; no alkssh-private dialect:
//! //!
//! - `alktty` (`adapter.rs`, `session.rs`, `local/pipe.rs`, //! - `alktty` (`adapter.rs`, `session.rs`, `local/pipe.rs`,
//! `local/pty.rs`): free `tracing::{debug, warn}` imports, //! `local/pty.rs`): free `tracing::{debug, warn}` imports,
@@ -18,7 +18,7 @@
//! (`listen = …`, `error = %e`, `reason = …`), levels `debug` for //! (`listen = …`, `error = %e`, `reason = …`), levels `debug` for
//! structural observations, `warn` for recoverable failures, //! structural observations, `warn` for recoverable failures,
//! `error` for abnormal pump terminations. //! `error` for abnormal pump terminations.
//! - `alkcall` (`protocol/*`, `channels/*`): the widest use — the same //! - `alkcall` (`protocol/*`, `channels/*`): the widest use, with the same
//! fields convention (`channel_id`, `request_id`, `error = %e`), //! fields convention (`channel_id`, `request_id`, `error = %e`),
//! component-prefixed messages (`"channel client: …"`, //! component-prefixed messages (`"channel client: …"`,
//! `"open wrapper: …"`), `debug`/`warn` only. //! `"open wrapper: …"`), `debug`/`warn` only.
@@ -27,7 +27,7 @@
//! - `alkgit`: no tracing at all at this writing. //! - `alkgit`: no tracing at all at this writing.
//! //!
//! Never observed in the family: `info!` / `trace!` events, subscriber //! Never observed in the family: `info!` / `trace!` events, subscriber
//! setup in library code, macro or span wrappers — so none here //! setup in library code, macro or span wrappers, so none here
//! either. Adopted verbatim: free `tracing` fn calls, message-last //! either. Adopted verbatim: free `tracing` fn calls, message-last
//! lowercase with the `"decline: …"` / `"serving path …"` phrasing, //! lowercase with the `"decline: …"` / `"serving path …"` phrasing,
//! flat per-event fields (`feature_build`, `row`, `channel_id`, //! flat per-event fields (`feature_build`, `row`, `channel_id`,
@@ -37,91 +37,91 @@
//! //!
//! Every event carries exactly: //! Every event carries exactly:
//! //!
//! - `feature_build = FEATURE_BUILD` — the crate's compile-time //! - `feature_build = FEATURE_BUILD`: the crate's compile-time
//! feature set as a stable string (`"none"`, `"git"`, //! feature set as a stable string (`"none"`, `"git"`,
//! `"session+git"`, …; features joined in the order `session, git, //! `"session+git"`, …; features joined in the order `session, git,
//! tunnel, sftp`), so a captured event is self-describing per //! tunnel, sftp`), so a captured event is self-describing per
//! build. The `sftp` feature gates no route, but ships in the //! build. The `sftp` feature gates no route, but ships in the
//! build string: it is part of what the assembly deployed. //! build string: it is part of what the assembly deployed.
//! - `row` — the greppable wire row the event is about: the channel //! - `row`: the greppable wire row the event is about, the channel
//! type for open-level (e.g. `"session"`, `"direct-tcpip"`, `"x11"`), //! type for open-level (e.g. `"session"`, `"direct-tcpip"`, `"x11"`),
//! the request name for request-level (e.g. `"pty_request"`, //! the request name for request-level (e.g. `"pty_request"`,
//! `"tcpip_forward"`), the handler hook name for auth (e.g. //! `"tcpip_forward"`), the handler hook name for auth (e.g.
//! `"auth_publickey"`), the session-row hook names for the //! `"auth_publickey"`), the session-row hook names for the
//! serving-path lifecycle. //! serving-path lifecycle.
//! - `channel_id` — the wire channel id (u32), on every event of a //! - `channel_id`: the wire channel id (u32), on every event of a
//! channel; the auth class has none (the auth hooks carry no //! channel; the auth class has none (the auth hooks carry no
//! channel at this surface). //! channel at this surface).
//! //!
//! Decline events additionally carry exactly: //! Decline events additionally carry exactly:
//! //!
//! - `decline_class` — `"open"` | `"request"` | `"serving"` | //! - `decline_class`: `"open"` | `"request"` | `"serving"` |
//! `"arrival"` | `"auth"`: //! `"arrival"` | `"auth"`:
//! the two wire decline classes of `mapping.md` §Wire decline //! the two wire decline classes of `mapping.md` §Wire decline
//! reasons, plus the serving-path class (this wave the seam stub's //! reasons, plus the serving-path class (this wave the seam stub's
//! decline *is* the serving path; the wave-3 mappings emit the same //! decline *is* the serving path; the wave-3 mappings emit the same
//! class from their real spawned paths) and the auth class (the //! class from their real spawned paths) and the auth class (the
//! disposition declines of ADR-004). //! disposition declines of ADR-004).
//! - `reason` — the decline cause, mirroring the architecture's own //! - `reason`: the decline cause, mirroring the architecture's own
//! classifications: `"unfeatured"` (feature-gate absence), //! classifications: `"unfeatured"` (feature-gate absence),
//! `"never-feature"` (ADR-003 §5 rows), `"cap-exceeded"`, //! `"never-feature"` (ADR-003 §5 rows), `"cap-exceeded"`,
//! `"connect-failed"` (`w3-tunnel-direct`: the tunnel rows' //! `"connect-failed"` (`w3-tunnel-direct`: the tunnel rows'
//! cannot-bridge class — the failed fabric open, the wave-2 seam //! cannot-bridge class: the failed fabric open, the wave-2 seam
//! stub's reason surviving as the real decline; also the //! stub's reason surviving as the real decline; also the
//! `forwarded-tcpip` stub's standing decline class), //! `forwarded-tcpip` stub's standing decline class),
//! `"seam-unconfigured"` (the config-driven row decline — the git //! `"seam-unconfigured"` (the config-driven row decline: the git
//! row and, now, the session rows (feature on, seam unset; //! row and, now, the session rows (feature on, seam unset;
//! `w3-git-mapping` / `w3-tty-session`) and the tunnel rows' absent //! `w3-git-mapping` / `w3-tty-session`) and the tunnel rows' absent
//! fabric handle even under the feature (`w3-tunnel-direct`; the //! fabric handle even under the feature (`w3-tunnel-direct`; the
//! unset-declines posture)), //! unset-declines posture)),
//! `"shell-unconfigured"` / `"wrapper-unconfigured"` (`w3-tty- //! `"shell-unconfigured"` / `"wrapper-unconfigured"` (`w3-tty-
//! session`: the OQ-08 unset-field rows — feature on, the //! session`: the OQ-08 unset-field rows (feature on, the
//! serve-config field unset, never a fallback), //! serve-config field unset, never a fallback),
//! `"negotiation-failed"` (`w3-tty-session`: the alktty //! `"negotiation-failed"` (`w3-tty-session`: the alktty
//! negotiation error frame the bridge received — the session never //! negotiation error frame the bridge received; the session never
//! started; the frame's JSON detail stays alktty-side), //! started; the frame's JSON detail stays alktty-side),
//! `"unknown-service"` / `"service-only"` / `"invalid-repo-id"` //! `"unknown-service"` / `"service-only"` / `"invalid-repo-id"`
//! (the exec grammar's git declinables — the parse core's two and //! (the exec grammar's git declinables: the parse core's two and
//! the git row's validation gate; no service token or repo string //! the git row's validation gate; no service token or repo string
//! ever rides — only the vocabulary value), //! ever rides, only the vocabulary value),
//! `"exec-oversize"` (the pre-parse `max_exec_command_len` cap), //! `"exec-oversize"` (the pre-parse `max_exec_command_len` cap),
//! `"invalid-target"` (`w3-tunnel-direct`: the direct rows' //! `"invalid-target"` (`w3-tunnel-direct`: the direct rows'
//! impossible wire target — an out-of-range `direct-tcpip` port), //! impossible wire target, an out-of-range `direct-tcpip` port),
//! `"unix-unavailable"` (`w3-tunnel-direct`: the OQ-11 runtime-Unix //! `"unix-unavailable"` (`w3-tunnel-direct`: the OQ-11 runtime-Unix
//! gate — the off-Unix `direct-streamlocal` decline, answered //! gate: the off-Unix `direct-streamlocal` decline, answered
//! before any fabric call), //! before any fabric call),
//! `"no-identity"` / `"channel-not-held"` (the git row's serving- //! `"no-identity"` / `"channel-not-held"` (the git row's serving-
//! path guards — unreachable in practice, fail-closed; the tunnel //! path guards (unreachable in practice, fail-closed); the tunnel
//! rows' open-level `no-identity` guard rides the same value — //! rows' open-level `no-identity` guard rides the same value,
//! reachable never, on the wire, for the same reason), //! reachable never, on the wire, for the same reason),
//! `"unimplemented"` (the subsystem rows — OQ-13), //! `"unimplemented"` (the subsystem rows, OQ-13),
//! `"declined-by-design"` (env, OQ-09), //! `"declined-by-design"` (env, OQ-09),
//! `"correlation-miss"` / `"cap-exceeded"` / `"accept-refused"` / //! `"correlation-miss"` / `"cap-exceeded"` / `"accept-refused"` /
//! `"unvanilla-inbound"` (`w3-tunnel-listen`: the `-R` leg's arrival //! `"unvanilla-inbound"` (`w3-tunnel-listen`: the `-R` leg's arrival
//! declines — the class whose events carry the minted `listen` token //! declines, the class whose events carry the minted `listen` token
//! instead of a channel id — and the unvanilla inbound //! instead of a channel id, and the unvanilla inbound
//! `forwarded-tcpip` open), //! `forwarded-tcpip` open),
//! `"listen-failed"` / `"no-such-listen"` (`w3-tunnel-listen`: the //! `"listen-failed"` / `"no-such-listen"` (`w3-tunnel-listen`: the
//! listen dial's failed/bounded outcome and the cancel forms' //! listen dial's failed/bounded outcome and the cancel forms'
//! not-found pair) — the global-request and arrival rows, and on the //! not-found pair), belonging to the global-request and arrival
//! auth class `"method-unimplemented"` / `"cert-unsupported"` / //! rows; on the auth class, `"method-unimplemented"` / `"cert-unsupported"` /
//! `"non-ed25519"` / `"fingerprint-unresolved"`. //! `"non-ed25519"` / `"fingerprint-unresolved"`.
//! Client-side (the dial handler's `server_channel_open_*` //! Client-side (the dial handler's `server_channel_open_*`
//! skeleton, `w4-dial`): `"no-acceptor"` (the decline-all default — //! skeleton, `w4-dial`): `"no-acceptor"` (the decline-all default:
//! a server-initiated open with no registered acceptor; the //! a server-initiated open with no registered acceptor; the
//! registry is `w4-reverse-listen`'s) and `"never-feature"` (the //! registry is `w4-reverse-listen`'s) and `"never-feature"` (the
//! x11/agent rows — ADR-003 §5). //! x11/agent rows, ADR-003 §5).
//! - `exec_route` — only on `exec_request`-row events: the dispatch //! - `exec_route` rides only `exec_request`-row events: the dispatch
//! route the parse routed to (`"git"` | `"pipe"`), so a git row's //! route the parse routed to (`"git"` | `"pipe"`), so a git row's
//! decline is distinguishable from a pipe row's in combined builds. //! decline is distinguishable from a pipe row's in combined builds.
//! Other rows carry none (a `None` field is not recorded by //! Other rows carry none (a `None` field is not recorded by
//! `tracing`). //! `tracing`).
//! //!
//! Levels: declines and the lifecycle's started/ended emit at //! Levels: declines and the lifecycle's started/ended emit at
//! `debug` — a decline is the door's normal, honest posture (vanilla //! `debug`: a decline is the door's normal, honest posture (vanilla
//! clients probe unfeatured rows routinely; nothing abnormal //! clients probe unfeatured rows routinely; nothing abnormal
//! happened), matching the family's structural-observation level. //! happened), matching the family's structural-observation level.
//! Only [`serving_path_failed`] is `warn` — a serving path that could //! Only [`serving_path_failed`] is `warn`: a serving path that could
//! not complete its wire reporting is a real failure (the family's //! not complete its wire reporting is a real failure (the family's
//! recoverable-failure level). //! recoverable-failure level).
//! //!
@@ -129,11 +129,11 @@
//! //!
//! **No secret material ever enters an event; never the username.** //! **No secret material ever enters an event; never the username.**
//! Structurally enforced, not recalled: the helpers take only //! Structurally enforced, not recalled: the helpers take only
//! `&'static str` vocabulary values and the wire channel id — there //! `&'static str` vocabulary values and the wire channel id; there
//! is no parameter a username, command string, env name/value, repo //! is no parameter a username, command string, env name/value, repo
//! path, key, or fingerprint could ride. In particular *the //! path, key, or fingerprint could ride. In particular *the
//! fingerprint stays out of the events* (`auth.md` §Dispositions: log //! fingerprint stays out of the events* (`auth.md` §Dispositions: log
//! exposure is deployment policy — the crate's events are //! exposure is deployment policy; the crate's events are
//! safe-by-default at every level, so the fingerprint is a //! safe-by-default at every level, so the fingerprint is a
//! deployer-side computation over its own logs, never crate-emitted). //! deployer-side computation over its own logs, never crate-emitted).
//! The `error` field carries only russh protocol errors. The wire //! The `error` field carries only russh protocol errors. The wire
@@ -141,7 +141,7 @@
//! appears in any captured field. If a future decline site needs //! appears in any captured field. If a future decline site needs
//! request-argument detail (e.g. the git grammar's decline reasons in //! request-argument detail (e.g. the git grammar's decline reasons in
//! `w3-git-mapping`), it must add an explicitly-vocabulary'd field and //! `w3-git-mapping`), it must add an explicitly-vocabulary'd field and
//! record its safety reasoning — dynamic strings do not ride this //! record its safety reasoning; dynamic strings do not ride this
//! module by default. //! module by default.
/// The compile-time feature set, as a stable string (`"none"`, /// The compile-time feature set, as a stable string (`"none"`,
@@ -194,7 +194,7 @@ pub(crate) fn decline_open(row: &'static str, reason: &'static str, channel_id:
/// build does not serve (`subsystem` / `env` / `x11` / agent, an /// build does not serve (`subsystem` / `env` / `x11` / agent, an
/// unfeatured parse route) or a not-yet-landed listen form. The wire /// unfeatured parse route) or a not-yet-landed listen form. The wire
/// answer is `channel_failure` (the channel stays usable) or the /// answer is `channel_failure` (the channel stays usable) or the
/// denied-request reply — the event is the deciding context. /// denied-request reply; the event is the deciding context.
pub(crate) fn decline_request( pub(crate) fn decline_request(
row: &'static str, row: &'static str,
reason: &'static str, reason: &'static str,
@@ -214,7 +214,7 @@ pub(crate) fn decline_request(
/// An auth-class decline: a disposition of the ADR-004 bridge turned /// An auth-class decline: a disposition of the ADR-004 bridge turned
/// the method away. No channel id exists at this surface; the /// the method away. No channel id exists at this surface; the
/// *fingerprint never rides the event* (see §Safety posture) — the /// *fingerprint never rides the event* (see §Safety posture); the
/// `reason` codes distinguish the decline causes without exposing who. /// `reason` codes distinguish the decline causes without exposing who.
pub(crate) fn decline_auth(row: &'static str, reason: &'static str) { pub(crate) fn decline_auth(row: &'static str, reason: &'static str) {
tracing::debug!( tracing::debug!(
@@ -226,7 +226,7 @@ pub(crate) fn decline_auth(row: &'static str, reason: &'static str) {
); );
} }
/// A request-level decline on a **global** request — the `-R` listen /// A request-level decline on a **global** request: the `-R` listen
/// forms (`mapping.md` §Global-request rows). No channel id exists at /// forms (`mapping.md` §Global-request rows). No channel id exists at
/// this surface; the fields are otherwise the `request` vocabulary. /// this surface; the fields are otherwise the `request` vocabulary.
pub(crate) fn decline_global_request(row: &'static str, reason: &'static str) { pub(crate) fn decline_global_request(row: &'static str, reason: &'static str) {
@@ -241,12 +241,12 @@ pub(crate) fn decline_global_request(row: &'static str, reason: &'static str) {
/// A decline on the `-R` leg's **arrival** surface (`w3-tunnel- /// A decline on the `-R` leg's **arrival** surface (`w3-tunnel-
/// listen`): a fabric inbound forwarded open that cannot become an /// listen`): a fabric inbound forwarded open that cannot become an
/// SSH channel — `correlation-miss` (no route for the minted token — /// SSH channel: `correlation-miss` (no route for the minted token,
/// teardown-at-birth), `cap-exceeded` (the per-arrival admission), or /// teardown-at-birth), `cap-exceeded` (the per-arrival admission), or
/// `accept-refused` (the vanilla client rejected the SSH open — the /// `accept-refused` (the vanilla client rejected the SSH open; the
/// count released, never a half-open channel). No SSH channel id /// count released, never a half-open channel). No SSH channel id
/// exists yet at this surface (that is the point of the declines), so /// exists yet at this surface (that is the point of the declines), so
/// the minted `listen` token — the correlation key itself — rides /// the minted `listen` token (the correlation key itself) rides
/// instead; the token is bookkeeping metadata (the producer's minted /// instead; the token is bookkeeping metadata (the producer's minted
/// correlation number), never secret material. /// correlation number), never secret material.
#[cfg(feature = "tunnel")] #[cfg(feature = "tunnel")]
@@ -265,7 +265,7 @@ pub(crate) fn decline_arrival(row: &'static str, reason: &'static str, listen: u
/// failure-then-close rather than serving. This wave that is the seam /// failure-then-close rather than serving. This wave that is the seam
/// stub (the wire shape pinned in `tests/spawn_return.rs`); the wave-3 /// stub (the wire shape pinned in `tests/spawn_return.rs`); the wave-3
/// mappings emit it from their real spawned paths wherever serving /// mappings emit it from their real spawned paths wherever serving
/// itself cannot proceed — the class the spawned paths inherit. /// itself cannot proceed, the class the spawned paths inherit.
#[cfg(any(feature = "git", feature = "session"))] #[cfg(any(feature = "git", feature = "session"))]
pub(crate) fn decline_serving( pub(crate) fn decline_serving(
row: &'static str, row: &'static str,
@@ -284,7 +284,7 @@ pub(crate) fn decline_serving(
); );
} }
/// Spawned-path started — emitted as the spawned task's first action, /// Spawned-path started, emitted as the spawned task's first action,
/// so "started" is the task's own honest start, not the hook's spawn /// so "started" is the task's own honest start, not the hook's spawn
/// call. The mapping tasks' serving paths inherit this shape. /// call. The mapping tasks' serving paths inherit this shape.
#[cfg(any(feature = "git", feature = "session", feature = "tunnel"))] #[cfg(any(feature = "git", feature = "session", feature = "tunnel"))]
@@ -302,7 +302,7 @@ pub(crate) fn serving_path_started(
); );
} }
/// Spawned-path ended — the serving future finished (cleanly, or with /// Spawned-path ended: the serving future finished (cleanly, or with
/// the decline it was wired to answer); emitted *after* the path's /// the decline it was wired to answer); emitted *after* the path's
/// wire reporting has completed. /// wire reporting has completed.
#[cfg(any(feature = "git", feature = "session", feature = "tunnel"))] #[cfg(any(feature = "git", feature = "session", feature = "tunnel"))]
@@ -320,14 +320,14 @@ pub(crate) fn serving_path_ended(
); );
} }
/// Spawned-path failure — the serving path could not complete its /// Spawned-path failure: the serving path could not complete its
/// wire reporting (the session handle's sends failed: the session is /// wire reporting (the session handle's sends failed: the session is
/// gone, or the report never landed). The vocabulary's one `warn` — /// gone, or the report never landed). The vocabulary's one `warn`,
/// the family's recoverable-failure level. Only russh protocol errors /// the family's recoverable-failure level. Only russh protocol errors
/// ride the `error` field (§Safety posture); the mapping tasks emit /// ride the `error` field (§Safety posture); the mapping tasks emit
/// this from their real paths when serving fails before reporting. /// this from their real paths when serving fails before reporting.
/// (The tunnel bridge never calls this: its wire reporting is /// (The tunnel bridge never calls this: its wire reporting is
/// open-level — `accept`/`reject` cannot fail send-wise — so the fn /// open-level (`accept`/`reject` cannot fail send-wise), so the fn
/// stays gated to the git/session rows.) /// stays gated to the git/session rows.)
#[cfg(any(feature = "git", feature = "session"))] #[cfg(any(feature = "git", feature = "session"))]
pub(crate) fn serving_path_failed<E: std::fmt::Debug>( pub(crate) fn serving_path_failed<E: std::fmt::Debug>(
@@ -350,7 +350,7 @@ pub(crate) fn serving_path_failed<E: std::fmt::Debug>(
mod tests { mod tests {
use tracing::subscriber::with_default; use tracing::subscriber::with_default;
/// A fake error shape — the failure row only pins that a Debug /// A fake error shape; the failure row only pins that a Debug
/// error value rides the `error` field. /// error value rides the `error` field.
#[derive(Debug)] #[derive(Debug)]
#[cfg(any(feature = "git", feature = "session"))] #[cfg(any(feature = "git", feature = "session"))]
@@ -362,10 +362,10 @@ mod tests {
/// A minimal field-capturing visitor shared by these rows and the /// A minimal field-capturing visitor shared by these rows and the
/// wire rows in `tests/observe.rs` (the test convention recorded /// wire rows in `tests/observe.rs` (the test convention recorded
/// in the task notes — the family has no event-capture precedent, /// in the task notes: the family has no event-capture precedent,
/// only alktunnels' fmt-subscriber dev-dep for debug output, so /// only alktunnels' fmt-subscriber dev-dep for debug output, so
/// the convention is a hand-rolled minimal [`tracing::Subscriber`] /// the convention is a hand-rolled minimal [`tracing::Subscriber`]
/// — no `tracing-subscriber` dependency). /// with no `tracing-subscriber` dependency).
struct Visitor(Vec<(String, String)>); struct Visitor(Vec<(String, String)>);
impl tracing::field::Visit for Visitor { impl tracing::field::Visit for Visitor {
@@ -379,7 +379,7 @@ mod tests {
} }
/// Captures the fields of the one event the emit fn fires, via /// Captures the fields of the one event the emit fn fires, via
/// the thread-local default subscriber (`with_default` — these /// the thread-local default subscriber (`with_default`; these
/// are direct helper rows; the spawned-task surfaces are driven /// are direct helper rows; the spawned-task surfaces are driven
/// over the wire in `tests/observe.rs` against the global /// over the wire in `tests/observe.rs` against the global
/// capture). /// capture).
@@ -425,8 +425,8 @@ mod tests {
.unwrap_or_else(|| panic!("no field {name} in {fields:?}")) .unwrap_or_else(|| panic!("no field {name} in {fields:?}"))
} }
/// - *Contract stamp:* the event vocabulary (`w3-decline-tracing`) /// - *Contract stamp:* the event vocabulary (`w3-decline-tracing`):
/// — an open decline carries exactly the base shape, values /// an open decline carries exactly the base shape, values
/// included. /// included.
#[test] #[test]
fn decline_open_carries_the_full_vocabulary() { fn decline_open_carries_the_full_vocabulary() {
@@ -449,11 +449,11 @@ mod tests {
"reason".to_owned(), "reason".to_owned(),
], ],
"an open decline carries exactly the vocabulary, nothing else \ "an open decline carries exactly the vocabulary, nothing else \
(the message field first — the event! macro's shape)" (the message field first, per the event! macro's shape)"
); );
} }
/// - *Contract stamp:* the `exec_route` field — present on the /// - *Contract stamp:* the `exec_route` field: present on the
/// exec row, absent elsewhere (a `None` field is not recorded /// exec row, absent elsewhere (a `None` field is not recorded
/// by `tracing`). /// by `tracing`).
#[test] #[test]
@@ -480,7 +480,7 @@ mod tests {
assert_eq!(field(&fields, "reason"), "method-unimplemented"); assert_eq!(field(&fields, "reason"), "method-unimplemented");
} }
/// - *Contract stamp:* the `exec_route` field — present on the /// - *Contract stamp:* the `exec_route` field: present on the
/// exec row, absent elsewhere (a `None` field is not recorded /// exec row, absent elsewhere (a `None` field is not recorded
/// by `tracing`). The pty/shell lifecycle rows carry no route. /// by `tracing`). The pty/shell lifecycle rows carry no route.
#[cfg(any(feature = "git", feature = "session"))] #[cfg(any(feature = "git", feature = "session"))]
@@ -503,9 +503,9 @@ mod tests {
assert_eq!(field(&fields, "error"), "Probe"); assert_eq!(field(&fields, "error"), "Probe");
} }
/// - *Contract stamp:* the arrival vocabulary (`w3-tunnel-listen`) /// - *Contract stamp:* the arrival vocabulary (`w3-tunnel-listen`):
/// — an arrival decline carries the minted `listen` token /// an arrival decline carries the minted `listen` token
/// instead of a channel id (none exists yet — that is the point /// instead of a channel id (none exists yet; that is the point
/// of the declines; the token is bookkeeping metadata, never /// of the declines; the token is bookkeeping metadata, never
/// secret material). /// secret material).
#[cfg(feature = "tunnel")] #[cfg(feature = "tunnel")]
@@ -530,7 +530,7 @@ mod tests {
/// The feature-build string: every enabled feature appears; a /// The feature-build string: every enabled feature appears; a
/// featureless build reads `none`. The expected half is derived /// featureless build reads `none`. The expected half is derived
/// from `cfg!` independently of the crate's match — divergence /// from `cfg!` independently of the crate's match, so divergence
/// breaks this row loudly. /// breaks this row loudly.
#[test] #[test]
fn feature_build_string_reflects_the_cfgs() { fn feature_build_string_reflects_the_cfgs() {
+11 -11
View File
@@ -1,22 +1,22 @@
//! The spawn-and-return plumbing (`w2-spawn-return`) — ADR-006 //! The spawn-and-return plumbing (`w2-spawn-return`): ADR-006
//! decision 4, the structural constraint of the whole spine, in code. //! decision 4, the structural constraint of the whole spine, in code.
//! //!
//! russh at this pin runs server handler callbacks **inline inside the //! russh at this pin runs server handler callbacks **inline inside the
//! session's packet loop** — a blocking handler suspends all packet //! session's packet loop**: a blocking handler suspends all packet
//! routing for the connection and deadlocks against the peer's //! routing for the connection and deadlocks against the peer's
//! immediate follow-up bytes (POC-3 proved by construction with //! immediate follow-up bytes (POC-3 proved by construction with
//! receive-pack). The crate's answer is mechanical, not conventional: //! receive-pack). The crate's answer is mechanical, not conventional:
//! //!
//! - **Every serving path is spawned** ([`spawn_serving_path`]) — the //! - **Every serving path is spawned** ([`spawn_serving_path`]): the
//! handler hook clones the session handle //! handler hook clones the session handle
//! (`Session::handle()`), hands it into its serving future, spawns, //! (`Session::handle()`), hands it into its serving future, spawns,
//! and returns. No serving path is ever awaited inline; the dispatch //! and returns. No serving path is ever awaited inline; the dispatch
//! seam stubs ([`crate::handler`]) hang off this helper, so the //! seam stubs ([`crate::handler`]) hang off this helper, so the
//! wave-3 mappings inherit the pattern mechanically from the stubs. //! wave-3 mappings inherit the pattern mechanically from the stubs.
//! The serving-path *events* (started / ended / failure — //! The serving-path *events* (started / ended / failure, via
//! [`crate::observe`]) are the spawned task's own actions: the stub //! [`crate::observe`]) are the spawned task's own actions: the stub
//! body emits them, the mappings' real paths will too. //! body emits them, the mappings' real paths will too.
//! - **Exit/status reporting rides the cloned handle** — the spawned //! - **Exit/status reporting rides the cloned handle**: the spawned
//! task's only report surface is the handle clone it was handed; //! task's only report surface is the handle clone it was handed;
//! [`report_exit_status_then_close`] preserves the wire ordering the //! [`report_exit_status_then_close`] preserves the wire ordering the
//! `mapping.md` `exit_status` row pins: `exit-status` sent **before** //! `mapping.md` `exit_status` row pins: `exit-status` sent **before**
@@ -28,7 +28,7 @@
//! //!
//! ## Test surface //! ## Test surface
//! //!
//! Both helpers are re-exported `#[doc(hidden)]` from the crate root — //! Both helpers are re-exported `#[doc(hidden)]` from the crate root,
//! the `handler.rs` §Test surface pattern: the mechanics-proof rows in //! the `handler.rs` §Test surface pattern: the mechanics-proof rows in
//! `tests/spawn_return.rs` compose them over the real wire (a minimal //! `tests/spawn_return.rs` compose them over the real wire (a minimal
//! proof handler standing in for the crate's own auth/dispatch hooks, //! proof handler standing in for the crate's own auth/dispatch hooks,
@@ -41,7 +41,7 @@ use russh::ChannelId;
use russh::server::Handle; use russh::server::Handle;
/// Spawns one serving path on its own task (ADR-006 decision 4): the /// Spawns one serving path on its own task (ADR-006 decision 4): the
/// caller — the handler hook — clones the session handle, hands it /// caller (the handler hook) clones the session handle, hands it
/// into the serving future, calls this, and returns immediately. /// into the serving future, calls this, and returns immediately.
/// Nothing here is ever awaited inline: an inline serve deadlocks /// Nothing here is ever awaited inline: an inline serve deadlocks
/// against the peer's follow-up bytes (POC-3 proved by construction). /// against the peer's follow-up bytes (POC-3 proved by construction).
@@ -50,7 +50,7 @@ pub fn spawn_serving_path(serving: impl Future<Output = ()> + Send + 'static) {
} }
/// The spawned task's exit report through the cloned handle: /// The spawned task's exit report through the cloned handle:
/// `exit-status(N)` then close — the ordering contract /// `exit-status(N)` then close; the ordering contract
/// (`mapping.md`'s `exit_status` row: sent **before** close, from the /// (`mapping.md`'s `exit_status` row: sent **before** close, from the
/// spawned task). A failed send means the session is gone; nothing is /// spawned task). A failed send means the session is gone; nothing is
/// reportable then. /// reportable then.
@@ -67,8 +67,8 @@ mod tests {
use super::spawn_serving_path; use super::spawn_serving_path;
/// - *Contract stamp:* ADR-006 decision 4 (spawn-and-return: the /// - *Contract stamp:* ADR-006 decision 4 (spawn-and-return: the
/// handler call returns while the serving path is still running /// handler call returns while the serving path is still running,
/// — handler-returns-fast is structural). /// so handler-returns-fast is structural).
#[tokio::test] #[tokio::test]
async fn spawn_returns_while_the_serving_path_is_still_pending() { async fn spawn_returns_while_the_serving_path_is_still_pending() {
let (release, waited) = tokio::sync::oneshot::channel::<()>(); let (release, waited) = tokio::sync::oneshot::channel::<()>();
@@ -82,7 +82,7 @@ mod tests {
assert!( assert!(
!done.load(Ordering::Acquire), !done.load(Ordering::Acquire),
"the spawn call returned while the serving path was still gated — \ "the spawn call returned while the serving path was still gated; \
no serving path is awaited inline" no serving path is awaited inline"
); );
+59 -58
View File
@@ -1,11 +1,11 @@
//! The session mapping (`w3-tty-session`) — the biggest single-row //! The session mapping (`w3-tty-session`), the biggest single-row
//! surface of the spine ([`crate::dispatch::ExecRoute::Pipe`] + the //! surface of the spine ([`crate::dispatch::ExecRoute::Pipe`] + the
//! `pty_request`/`shell_request` rows): pty/shell/pipe-exec serve //! `pty_request`/`shell_request` rows): pty/shell/pipe-exec serve
//! through the published alktty seam //! through the published alktty seam
//! (`drive_session_pre_negotiated(writer, reader, req, backends, //! (`drive_session_pre_negotiated(writer, reader, req, backends,
//! ownership, identity)`) over a **translation bridge, not a byte //! ownership, identity)`) over a **translation bridge, not a byte
//! pump** (POC-3 §D.1): alktty's data plane is chunk-framed both //! pump** (POC-3 §D.1): alktty's data plane is chunk-framed both
//! directions (`alktty::wire` — 5-byte header + payload), the SSH //! directions (`alktty::wire`, 5-byte header + payload), the SSH
//! channel is a plain byte plane, so the bridge wraps the channel //! channel is a plain byte plane, so the bridge wraps the channel
//! with small adapters around a pair of in-memory duplexes. //! with small adapters around a pair of in-memory duplexes.
//! //!
@@ -15,7 +15,7 @@
//! //!
//! - `pty_request` args → `alktty::negotiation::TerminalParamsWire` //! - `pty_request` args → `alktty::negotiation::TerminalParamsWire`
//! (`term`/`cols`/`rows`/pixel dims; SSH ttymodes → the **reserved //! (`term`/`cols`/`rows`/pixel dims; SSH ttymodes → the **reserved
//! modes JSON `{}`** — alktty OQ-44's reservation: backends must //! modes JSON `{}`** per alktty OQ-44's reservation: backends must
//! ignore modes in v1, so the door writes `{}` and defers //! ignore modes in v1, so the door writes `{}` and defers
//! faithfully). Stored per channel (the POC-3 accepted-but-held //! faithfully). Stored per channel (the POC-3 accepted-but-held
//! shape, [`crate::handler`]'s store) and consumed by the later //! shape, [`crate::handler`]'s store) and consumed by the later
@@ -24,29 +24,29 @@
//! - `shell_request` → `NegotiateRequest{carriage: "raw", backend: //! - `shell_request` → `NegotiateRequest{carriage: "raw", backend:
//! the seam selector, tty: the consumed pty params, cmd: the //! the seam selector, tty: the consumed pty params, cmd: the
//! serve-config shell argv **verbatim**, cwd: session_cwd, env: //! serve-config shell argv **verbatim**, cwd: session_cwd, env:
//! session_env}` — the wire never carries command, args, cwd or //! session_env}`, and the wire never carries command, args, cwd or
//! env (OQ-08/OQ-09; the `env_request` row stays declined //! env (OQ-08/OQ-09; the `env_request` row stays declined
//! unconditionally). **A `shell_request` without a preceding pty //! unconditionally). **A `shell_request` without a preceding pty
//! serves pipe-mode (`tty = None`)** — decided: vanilla sshd serves //! serves pipe-mode (`tty = None`)**, decided: vanilla sshd serves
//! a shell without a terminal too (`ssh -T host` is legitimate //! a shell without a terminal too (`ssh -T host` is legitimate
//! vanilla), so a pty-less shell request is served, never declined. //! vanilla), so a pty-less shell request is served, never declined.
//! - `exec_request` (pipe row) → `cmd = exec_wrapper_argv ++ [cmd]` //! - `exec_request` (pipe row) → `cmd = exec_wrapper_argv ++ [cmd]`
//! (the OQ-08 argv append — vanilla quoting semantics; the wire //! (the OQ-08 argv append, vanilla quoting semantics; the wire
//! command string is **never naively split**), `tty = //! command string is **never naively split**), `tty =
//! Some(consumed pty)` when a pty preceded, else `None` (pipe //! Some(consumed pty)` when a pty preceded, else `None` (pipe
//! mode). //! mode).
//! - **Unset-declines** (OQ-08, never a fallback): `shell_argv` //! - **Unset-declines** (OQ-08, never a fallback): `shell_argv`
//! unset → `shell_request` declines; `exec_wrapper_argv` unset → //! unset → `shell_request` declines; `exec_wrapper_argv` unset →
//! the pipe row declines. Wire shape (decided + recorded): the //! the pipe row declines. Wire shape (decided + recorded): the
//! **serving-path shape — `channel_failure` then channel close, //! **serving-path shape: `channel_failure` then channel close,
//! from a spawned task** ([`crate::serve`]'s mechanics; the same //! from a spawned task** ([`crate::serve`]'s mechanics; the same
//! spawned-stub body the git row uses) — the class of a routable //! spawned-stub body the git row uses), the class of a routable
//! row that owns the request's whole outcome; the session channel //! row that owns the request's whole outcome; the session channel
//! closes the way it would when the request itself cannot exist. //! closes the way it would when the request itself cannot exist.
//! `session_seam` unset (feature on, seam absent) → the same //! `session_seam` unset (feature on, seam absent) → the same
//! shape, reason `seam-unconfigured` (the decline matrix's //! shape, reason `seam-unconfigured` (the decline matrix's
//! config-driven note); `pty_request` checks the seam too — a pty //! config-driven note); `pty_request` checks the seam too, since a
//! whose session can never serve is a doomed allocation. Field //! pty whose session can never serve is a doomed allocation. Field
//! checks precede the seam check (the row's own unset field is the //! checks precede the seam check (the row's own unset field is the
//! first decline); the decline never takes the channel and never //! first decline); the decline never takes the channel and never
//! consumes stored pty state. //! consumes stored pty state.
@@ -63,10 +63,10 @@
//! - **SSH → chunks** (the `client_recv` side of alktty's session): //! - **SSH → chunks** (the `client_recv` side of alktty's session):
//! a bounded [`tokio::sync::mpsc`] (the //! a bounded [`tokio::sync::mpsc`] (the
//! [`CONTROL_SURFACE_BOUND`] bound) feeds a framing task that //! [`CONTROL_SURFACE_BOUND`] bound) feeds a framing task that
//! writes `alktty::wire::Chunk`s into the duplex — the same mpsc //! writes `alktty::wire::Chunk`s into the duplex, the same mpsc
//! the control rows ride ([`BridgeSurface`], §below). SSH `data` → //! the control rows ride ([`BridgeSurface`], §below). SSH `data` →
//! `Chunk::stdin`; SSH channel EOF → **the zero-length //! `Chunk::stdin`; SSH channel EOF → **the zero-length
//! stdin-chunk sentinel** — decided (verified against alktty 0.8.1's //! stdin-chunk sentinel**, decided (verified against alktty 0.8.1's
//! adapter pump, `adapter.rs::pump_client_to_backend`): a //! adapter pump, `adapter.rs::pump_client_to_backend`): a
//! zero-length stdin chunk shuts the backend's stdin, *identical //! zero-length stdin chunk shuts the backend's stdin, *identical
//! in effect* to `ControlMessage::Eof` on `STREAM_CTRL_IN`; the //! in effect* to `ControlMessage::Eof` on `STREAM_CTRL_IN`; the
@@ -80,9 +80,9 @@
//! *error frame* is length-prefixed JSON whose first length byte //! *error frame* is length-prefixed JSON whose first length byte
//! is `0x00`; a raw chunk's first byte is a `stream_type` ≤ 4). //! is `0x00`; a raw chunk's first byte is a `stream_type` ≤ 4).
//! stdout chunks → SSH `data`; stderr chunks → SSH //! stdout chunks → SSH `data`; stderr chunks → SSH
//! `extended_data(1)` (PTY mode merges upstream — the backend's //! `extended_data(1)` (PTY mode merges upstream; the backend's
//! stderr stream is `None` there, so the translation is universal: //! stderr stream is `None` there, so the translation is universal
//! no mode-switch exists); the exit chunk (`STREAM_CTRL_OUT`) → //! and no mode-switch exists); the exit chunk (`STREAM_CTRL_OUT`) →
//! the derivation contract (§below). No unbounded buffering in //! the derivation contract (§below). No unbounded buffering in
//! either adapter (AGENTS.md convention 9). //! either adapter (AGENTS.md convention 9).
//! - **The negotiation-error frame is the bridge's serving-path //! - **The negotiation-error frame is the bridge's serving-path
@@ -91,23 +91,24 @@
//! ownership), and a failure answers a length-prefixed error //! ownership), and a failure answers a length-prefixed error
//! frame instead of chunks. Decided + recorded: the bridge maps //! frame instead of chunks. Decided + recorded: the bridge maps
//! that frame to a serving-path decline event //! that frame to a serving-path decline event
//! (`negotiation-failed`) and an **`exit-status(255)` then close** //! (`negotiation-failed`) and an **`exit-status(255)` then close**,
//! — the one place this door claims 255: the session **never //! the one place this door claims 255: the session **never
//! started** (no exit chunk exists, nothing ran, so 255 is the //! started** (no exit chunk exists, nothing ran, so 255 is the
//! honest could-not-start semantic — contrast the git row, whose //! honest could-not-start semantic, contrasted with the git row,
//! whose
//! service *runs* its substrate and honestly reports `1`). The //! service *runs* its substrate and honestly reports `1`). The
//! error frame's JSON detail stays alktty-side; only the //! error frame's JSON detail stays alktty-side; only the
//! vocabulary value rides the event. //! vocabulary value rides the event.
//! - A bridge read that ends **without** an exit chunk reports //! - A bridge read that ends **without** an exit chunk reports
//! nothing (the chunk is the sole fidelity input; its absence is //! nothing (the chunk is the sole fidelity input; its absence is
//! neither 0 nor 255 — no fabricated report): plain close, and //! neither 0 nor 255, no fabricated report): plain close, and
//! the lifecycle's ended event. //! the lifecycle's ended event.
//! - **Known limit (recorded disposition, wave-3 gate booked item 3): //! - **Known limit (recorded disposition, wave-3 gate booked item 3):
//! the stdout [`tokio::io::AsyncWriteExt::write_all`] carries no //! the stdout [`tokio::io::AsyncWriteExt::write_all`] carries no
//! deadline.** Backpressure is bounded (the bounded bridging duplex, //! deadline.** Backpressure is bounded (the bounded bridging duplex,
//! russh's per-channel window and the client's own flow control bound //! russh's per-channel window and the client's own flow control bound
//! what can pile up) and liveness is covered by the inactivity //! what can pile up) and liveness is covered by the inactivity
//! timeout ([`crate::Limits::inactivity_timeout`], always set) — a //! timeout ([`crate::Limits::inactivity_timeout`], always set); a
//! deadline here would be a behavior change (a slow client may stall //! deadline here would be a behavior change (a slow client may stall
//! its stream, vanilla sshd's posture included), not a coverage //! its stream, vanilla sshd's posture included), not a coverage
//! minor. So the limit is recorded, not "fixed". //! minor. So the limit is recorded, not "fixed".
@@ -116,7 +117,7 @@
//! //!
//! The requests arrive on the packet loop after the serving task //! The requests arrive on the packet loop after the serving task
//! owns the wire, and reach the spawned bridge through the //! owns the wire, and reach the spawned bridge through the
//! **per-channel control surface** ([`BridgeSurface`] — handler //! **per-channel control surface** ([`BridgeSurface`], handler
//! state while the row serves): a bounded mpsc whose send half //! state while the row serves): a bounded mpsc whose send half
//! forward_control `try_send`s the control chunk (never awaited on //! forward_control `try_send`s the control chunk (never awaited on
//! the packet loop). `window_change_request` → //! the packet loop). `window_change_request` →
@@ -124,23 +125,23 @@
//! (alktty's uppercase-name vocabulary). Decisions, recorded: //! (alktty's uppercase-name vocabulary). Decisions, recorded:
//! the SSH control rows are fire-and-forget (no reply exists on //! the SSH control rows are fire-and-forget (no reply exists on
//! the wire), so **a full or closed surface drops the control //! the wire), so **a full or closed surface drops the control
//! event** — the bound keeps a stalled bridge from stalling the //! event**; the bound keeps a stalled bridge from stalling the
//! packet loop (bounded resources, convention 9); **signal names //! packet loop (bounded resources, convention 9); **signal names
//! are accepted verbatim** — no alkssh-side name filter: the //! are accepted verbatim**, no alkssh-side name filter: the
//! backend owns the rejection semantics (alktty passes the name to //! backend owns the rejection semantics (alktty passes the name to
//! the backend's control handle, whose semantics decide), and the //! the backend's control handle, whose semantics decide), and the
//! pipe-mode pid-only vs PTY process-group *scope* is backend //! pipe-mode pid-only vs PTY process-group *scope* is backend
//! semantics (the mapping row note), so there is **no decline //! semantics (the mapping row note), so there is **no decline
//! shape** for a name — nothing on the wire can answer. A resize //! shape** for a name; nothing on the wire can answer. A resize
//! arriving for a channel whose pty is stored but not yet serving //! arriving for a channel whose pty is stored but not yet serving
//! updates the stored params instead (the final size wins — //! updates the stored params instead (the final size wins,
//! vanilla sshd's own ordering); a resize with neither a serving //! vanilla sshd's own ordering); a resize with neither a serving
//! bridge nor a stored pty is dropped. //! bridge nor a stored pty is dropped.
//! //!
//! ## The exit derivation contract (mapping.md §Exit chunk, rules 1–6) //! ## The exit derivation contract (mapping.md §Exit chunk, rules 1–6)
//! //!
//! Normative text and the whole derivation machinery now live in //! Normative text and the whole derivation machinery now live in
//! [`crate::exit_code`] — the shared exit-currency module consumed by //! [`crate::exit_code`], the shared exit-currency module consumed by
//! *both* directions (`w4-op-rows` extracted it out of this module: //! *both* directions (`w4-op-rows` extracted it out of this module:
//! the client rows consume wire exit reports back into the family's //! the client rows consume wire exit reports back into the family's
//! `i32` exit-code convention, the exact inverse arm of the same //! `i32` exit-code convention, the exact inverse arm of the same
@@ -156,11 +157,11 @@
//! //!
//! The serving task watches two signals: the **per-channel cancel** //! The serving task watches two signals: the **per-channel cancel**
//! (fired by `channel_close`, or surfaced by the surface's drop) and //! (fired by `channel_close`, or surfaced by the surface's drop) and
//! the **connection teardown** ([`Teardown`] — fired by the //! the **connection teardown** ([`Teardown`], fired by the
//! handler's `Drop`; the russh session ended, however it ended). On //! handler's `Drop`; the russh session ended, however it ended). On
//! either, the task aborts the alktty session task — dropping its //! either, the task aborts the alktty session task, dropping its
//! not-yet-resolved `exit_code` future, which is alktty's //! not-yet-resolved `exit_code` future (alktty's
//! kill-on-drop contract firing (its ADR-056) — then closes the SSH //! kill-on-drop contract firing, its ADR-056), then closes the SSH
//! channel and ends. Nothing is fabricated on a cancel. The //! channel and ends. Nothing is fabricated on a cancel. The
//! detached stdin/framing tasks are bounded by their duplex/mpsc/ //! detached stdin/framing tasks are bounded by their duplex/mpsc/
//! russh-window bounds and end on the same signals' effects. //! russh-window bounds and end on the same signals' effects.
@@ -182,7 +183,7 @@ use crate::config::SessionSeam;
use crate::exit_code::{ExitReport, derive_exit, sig_from_name}; use crate::exit_code::{ExitReport, derive_exit, sig_from_name};
use crate::observe; use crate::observe;
/// The negotiation frame the bridge speaks locally — every locally /// The negotiation frame the bridge speaks locally; every locally
/// constructed request reads `"raw"` (alktty's v1 carriage). /// constructed request reads `"raw"` (alktty's v1 carriage).
pub(crate) const CARRIAGE: &str = "raw"; pub(crate) const CARRIAGE: &str = "raw";
@@ -192,11 +193,11 @@ pub(crate) const CARRIAGE: &str = "raw";
/// (§the control rows). The shim's scripted rows exercise a handful. /// (§the control rows). The shim's scripted rows exercise a handful.
pub(crate) const CONTROL_SURFACE_BOUND: usize = 64; pub(crate) const CONTROL_SURFACE_BOUND: usize = 64;
/// The translation bridge duplex capacity — the wave-2 loopback /// The translation bridge duplex capacity, the wave-2 loopback
/// harness's per-direction convention, kept for the bridge pairs. /// harness's per-direction convention, kept for the bridge pairs.
const BRIDGE_DUPLEX_CAPACITY: usize = 65_536; const BRIDGE_DUPLEX_CAPACITY: usize = 65_536;
/// The stdin pump's read buffer — the SSH-side chunk granularity /// The stdin pump's read buffer, the SSH-side chunk granularity
/// (per-chunk payloads are bounded in practice by russh's /// (per-chunk payloads are bounded in practice by russh's
/// `maximum_packet_size`, here by [`MAX_CHUNK_LEN`] on the frame). /// `maximum_packet_size`, here by [`MAX_CHUNK_LEN`] on the frame).
const STDIN_PUMP_BUF: usize = 16 * 1024; const STDIN_PUMP_BUF: usize = 16 * 1024;
@@ -210,7 +211,7 @@ pub(crate) fn clamp_u16(value: u32) -> u16 {
/// The `pty_request` args → `TerminalParamsWire` conversion (§the /// The `pty_request` args → `TerminalParamsWire` conversion (§the
/// negotiation section): SSH ttymodes → the **reserved modes JSON /// negotiation section): SSH ttymodes → the **reserved modes JSON
/// `{}`** (alktty OQ-44's reservation — the map is honest: nothing /// `{}`** (alktty OQ-44's reservation; the map is honest: nothing
/// is carried, exactly what the reservation asks). /// is carried, exactly what the reservation asks).
pub fn terminal_params( pub fn terminal_params(
term: &str, term: &str,
@@ -251,15 +252,15 @@ pub(crate) fn negotiate_request(
} }
} }
/// One parsed bridge-side frame from alktty's `client_send` half — /// One parsed bridge-side frame from alktty's `client_send` half:
/// the 4-byte first-window disambiguation's outcome (§the /// the 4-byte first-window disambiguation's outcome (§the
/// translation bridge). /// translation bridge).
pub(crate) enum BridgeFrame { pub(crate) enum BridgeFrame {
/// A raw alktty chunk (data or server→client control). /// A raw alktty chunk (data or server→client control).
Chunk(Chunk), Chunk(Chunk),
/// The length-prefixed negotiation error frame — the session /// The length-prefixed negotiation error frame: the session
/// failed validation/allocation/ownership at the seam. The bytes /// failed validation/allocation/ownership at the seam. The bytes
/// are alktty-side detail (never read here — only the /// are alktty-side detail (never read here, only the
/// vocabulary value rides the decline event). /// vocabulary value rides the decline event).
NegotiationError, NegotiationError,
/// The half ended (alktty's task completed or was cancelled) /// The half ended (alktty's task completed or was cancelled)
@@ -269,10 +270,10 @@ pub(crate) enum BridgeFrame {
Break, Break,
} }
/// Reads one bridge frame — the disambiguation invariant: the first /// Reads one bridge frame (the disambiguation invariant: the first
/// byte `0x00` is a negotiation error frame's length-prefix high /// byte `0x00` is a negotiation error frame's length-prefix high
/// byte; anything ≤ 4 is a chunk `stream_type`. Bounds-checked /// byte; anything ≤ 4 is a chunk `stream_type`). Bounds-checked
/// against [`MAX_CHUNK_LEN`] both directions — a rogue length never /// against [`MAX_CHUNK_LEN`] both directions, so a rogue length never
/// triggers an oversized read. /// triggers an oversized read.
pub(crate) async fn read_bridge_frame( pub(crate) async fn read_bridge_frame(
reader: &mut (impl tokio::io::AsyncRead + Unpin), reader: &mut (impl tokio::io::AsyncRead + Unpin),
@@ -318,8 +319,8 @@ pub(crate) async fn read_bridge_frame(
/// The exit chunk's JSON payload → the derived code /// The exit chunk's JSON payload → the derived code
/// (`{"type":"exit","code":N}`). Anything else on `STREAM_CTRL_OUT` /// (`{"type":"exit","code":N}`). Anything else on `STREAM_CTRL_OUT`
/// has no contract meaning — and an exit chunk that cannot parse has /// has no contract meaning, and an exit chunk that cannot parse has
/// no fidelity input — both map to `None` (no report; the plain /// no fidelity input; both map to `None` (no report; the plain
/// close posture). /// close posture).
fn exit_chunk_code(payload: &[u8]) -> Option<i32> { fn exit_chunk_code(payload: &[u8]) -> Option<i32> {
ControlMessage::from_slice(payload) ControlMessage::from_slice(payload)
@@ -330,7 +331,7 @@ fn exit_chunk_code(payload: &[u8]) -> Option<i32> {
}) })
} }
/// The per-channel bridge surface — handler state while a session row /// The per-channel bridge surface: handler state while a session row
/// serves (§the control rows). The handler's `window_change_request` /// serves (§the control rows). The handler's `window_change_request`
/// / `signal` hooks reach the spawned bridge **through this struct** /// / `signal` hooks reach the spawned bridge **through this struct**
/// (the requests arrive on the packet loop after the serving task /// (the requests arrive on the packet loop after the serving task
@@ -338,17 +339,17 @@ fn exit_chunk_code(payload: &[u8]) -> Option<i32> {
/// per-channel cancel signal `channel_close` fires. Packet-loop only /// per-channel cancel signal `channel_close` fires. Packet-loop only
/// (single-writer; no lock). /// (single-writer; no lock).
pub(crate) struct BridgeSurface { pub(crate) struct BridgeSurface {
/// The control-surface send half — the same bounded mpsc the /// The control-surface send half, the same bounded mpsc the
/// serving task's stdin pump feeds. /// serving task's stdin pump feeds.
pub(crate) sender: mpsc::Sender<Chunk>, pub(crate) sender: mpsc::Sender<Chunk>,
/// The per-channel cancel signal — `channel_close` fires it. The /// The per-channel cancel signal (`channel_close` fires it). The
/// serving task interprets the fire *or* the half's drop as the /// serving task interprets the fire *or* the half's drop as the
/// cancel. /// cancel.
pub(crate) cancel: watch::Sender<bool>, pub(crate) cancel: watch::Sender<bool>,
} }
impl BridgeSurface { impl BridgeSurface {
/// Forwards one control row into the bridge — `try_send`, never /// Forwards one control row into the bridge via `try_send`, never
/// awaited on the packet loop (§the control rows); a full or /// awaited on the packet loop (§the control rows); a full or
/// closed surface drops the event. /// closed surface drops the event.
pub(crate) fn forward_control(&self, message: &ControlMessage) -> bool { pub(crate) fn forward_control(&self, message: &ControlMessage) -> bool {
@@ -404,12 +405,12 @@ impl Teardown {
} }
} }
/// Reads the SSH half of the channel and feeds stdin chunks — the /// Reads the SSH half of the channel and feeds stdin chunks, the
/// SSH `data` → `Chunk::stdin` adapter (§the translation bridge). /// SSH `data` → `Chunk::stdin` adapter (§the translation bridge).
/// On EOF or a read error it sends **the zero-length stdin-chunk /// On EOF or a read error it sends **the zero-length stdin-chunk
/// sentinel** (the EOF translation decision: identical in effect to /// sentinel** (the EOF translation decision: identical in effect to
/// alktty's `ControlMessage::Eof` — the backend's stdin shuts while /// alktty's `ControlMessage::Eof`, with the backend's stdin shut
/// the session keeps pumping) and ends. /// while the session keeps pumping) and ends.
async fn stdin_pump( async fn stdin_pump(
ssh_read: impl tokio::io::AsyncRead + Unpin + 'static, ssh_read: impl tokio::io::AsyncRead + Unpin + 'static,
chunks: mpsc::Sender<Chunk>, chunks: mpsc::Sender<Chunk>,
@@ -450,9 +451,9 @@ async fn stdin_pump(
} }
/// Consumes the framed-input mpsc and writes alktty chunks into the /// Consumes the framed-input mpsc and writes alktty chunks into the
/// session's `client_recv` duplex half — the framing task. Ends after /// session's `client_recv` duplex half, the framing task. Ends after
/// the EOF sentinel is written (nothing rides the data path past /// the EOF sentinel is written (nothing rides the data path past
/// SSH EOF), on a mpsc close (every sender gone — the cancelled or /// SSH EOF), on a mpsc close (every sender gone, the cancelled or
/// completed session), or on a framing failure. /// completed session), or on a framing failure.
async fn framing_task(mut chunks: mpsc::Receiver<Chunk>, writer: tokio::io::DuplexStream) { async fn framing_task(mut chunks: mpsc::Receiver<Chunk>, writer: tokio::io::DuplexStream) {
let mut writer = ChunkWriter::new(writer); let mut writer = ChunkWriter::new(writer);
@@ -468,7 +469,7 @@ async fn framing_task(mut chunks: mpsc::Receiver<Chunk>, writer: tokio::io::Dupl
/// The exit report + close + lifecycle event, in the ADR-006 /// The exit report + close + lifecycle event, in the ADR-006
/// decision-4 order: **status before close, from the spawned task**. /// decision-4 order: **status before close, from the spawned task**.
/// A failed send means the session is gone — the lifecycle's one /// A failed send means the session is gone; the lifecycle's one
/// `warn` records it (the report that could not land). /// `warn` records it (the report that could not land).
async fn report_exit( async fn report_exit(
handle: &Handle, handle: &Handle,
@@ -501,7 +502,7 @@ async fn report_exit(
} }
} }
/// Fails the request and closes the channel from the serving path — /// Fails the request and closes the channel from the serving path,
/// the serving-path decline shape ([`crate::serve`]'s mechanics) with /// the serving-path decline shape ([`crate::serve`]'s mechanics) with
/// the lifecycle events and the given reason. Used for `no-identity`. /// the lifecycle events and the given reason. Used for `no-identity`.
async fn fail_and_close( async fn fail_and_close(
@@ -528,7 +529,7 @@ async fn fail_and_close(
/// [`alktty::drive_session_pre_negotiated`]; the alktty session task /// [`alktty::drive_session_pre_negotiated`]; the alktty session task
/// and the SSH→chunks pump are spawned here, and *this* future drives /// and the SSH→chunks pump are spawned here, and *this* future drives
/// the chunks→SSH direction inline so the exit report is the path's /// the chunks→SSH direction inline so the exit report is the path's
/// own final act (status before close — ADR-006 decision 4). The /// own final act (status before close, per ADR-006 decision 4). The
/// channel was taken by the dispatch; the spawned task owns the /// channel was taken by the dispatch; the spawned task owns the
/// stream and its report surface. /// stream and its report surface.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -688,14 +689,14 @@ async fn drive_chunks_to_ssh(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
/// - *Contract stamp:* the no-exit-chunk close family's frame /// - *Contract stamp:* the no-exit-chunk close family's frame
/// classification (`w4-tty-coverage` — the wire row in /// classification (`w4-tty-coverage`; the wire row in
/// `tests/tty_mapping.rs` lands the family's serving outcome): a /// `tests/tty_mapping.rs` lands the family's serving outcome): a
/// clean read-end classifies `Closed`, an oversized claimed /// clean read-end classifies `Closed`, an oversized claimed
/// length classifies `Break` — bounds-checked both directions, /// length classifies `Break` (bounds-checked both directions,
/// never an oversized read. `Break`'s serving arm is one body /// never an oversized read). `Break`'s serving arm is one body
/// with `Closed`'s; through alktty's own validated `ChunkWriter` /// with `Closed`'s; through alktty's own validated `ChunkWriter`
/// a `Break` frame is unreachable on the duplex (the drainer /// a `Break` frame is unreachable on the duplex (the drainer
/// rejects the oversized chunk locally and the read sees EOF — /// rejects the oversized chunk locally and the read sees EOF,
/// the wire row's construction), so the classification rows pin /// the wire row's construction), so the classification rows pin
/// the defensive arm at the frame layer. /// the defensive arm at the frame layer.
#[tokio::test] #[tokio::test]
+196 -93
View File
@@ -1,21 +1,23 @@
//! The `direct-tcpip` / `direct-streamlocal` mapping — the tunnel //! The `direct-tcpip` / `direct-streamlocal` mapping, the tunnel
//! branch's route-1 bridge (`w3-tunnel-direct`, ADR-006 decision 1): //! branch's route-1 bridge (`w3-tunnel-direct`, ADR-006 decision 1):
//! the adapter-side bridge POC-3 stage C proved, re-landed as contract //! the adapter-side bridge POC-3 stage C proved, re-landed as contract
//! against published alktunnels 0.4.1. //! against published alktunnels 0.4.1, with the forwarded serving
//! surface adopted to alktunnels 0.5.0's establisher shape (the
//! review-003 W2 adoption, the `ForwardedGate` correlation gate).
//! //!
//! ## The route-1 bridge (ADR-006 decision 1) //! ## The route-1 bridge (ADR-006 decision 1)
//! //!
//! alkssh holds the assembly-supplied `Arc<ChannelClient>` into the //! alkssh holds the assembly-supplied `Arc<ChannelClient>` into the
//! family fabric (the serve-config's `channel_client` — the wave-1 //! family fabric (the serve-config's `channel_client`, the wave-1
//! config field's consuming code). Per accepted `direct-tcpip` / //! config field's consuming code). Per accepted `direct-tcpip` /
//! `direct-streamlocal` open, the target/substrate are constructed //! `direct-streamlocal` open, the target/substrate are constructed
//! from the SSH open arguments (`direct-tcpip`(host, port) → //! from the SSH open arguments (`direct-tcpip`(host, port) →
//! `SubstrateAddr::HostPort` + `Substrate::Tcp`; //! `SubstrateAddr::HostPort` + `Substrate::Tcp`;
//! `direct-streamlocal`(socket path) → `SubstrateAddr::Path` + //! `direct-streamlocal`(socket path) → `SubstrateAddr::Path` +
//! `Substrate::Unix` — the mapping.md row's treatment) and the flow is //! `Substrate::Unix`, the mapping.md row's treatment) and the flow is
//! exactly the POC recipe: **`TunnelSession::open_direct(&client, //! exactly the POC recipe: **`TunnelSession::open_direct(&client,
//! target, substrate)` → accept only after the fabric open succeeds** //! target, substrate)` → accept only after the fabric open succeeds**
//! (`ChannelOpenHandle::accept` consumes the handle — the reject shape //! (`ChannelOpenHandle::accept` consumes the handle; the reject shape
//! is only possible pre-accept, which is what makes accept-after-open //! is only possible pre-accept, which is what makes accept-after-open
//! the only honest flow) → on success `take_halves()` pumped against //! the only honest flow) → on success `take_halves()` pumped against
//! the taken channel's stream ([`alkcall::channels::pump::pump_bidi`], //! the taken channel's stream ([`alkcall::channels::pump::pump_bidi`],
@@ -23,57 +25,58 @@
//! the spawn-and-return constraint. The open hook returns fast with //! the spawn-and-return constraint. The open hook returns fast with
//! the fabric open in flight. //! the fabric open in flight.
//! //!
//! **Fabric failure → the explicit `ConnectFailed` reject** — the //! **Fabric failure → the explicit `ConnectFailed` reject**; the
//! wave-2 seam stub's reason surviving as the real decline //! wave-2 seam stub's reason surviving as the real decline
//! ([`crate::dispatch::connect_failed_open_decline`]; the stub itself //! ([`crate::dispatch::connect_failed_open_decline`]; the stub itself
//! is retired). The channel is never established, so the SSH client //! is retired). The channel is never established, so the SSH client
//! observes a clean, channel-scoped open refusal (POC-1 §5 posture) //! observes a clean, channel-scoped open refusal (POC-1 §5 posture)
//! — vanilla clients retry-observe a down forward instead of a hang. //! : vanilla clients retry-observe a down forward instead of a hang.
//! //!
//! ## The `-R` listen flow (`w3-tunnel-listen` — the fabric listens, //! ## The `-R` listen flow (`w3-tunnel-listen`: the fabric listens,
//! the client dials) //! the client dials)
//! //!
//! The server direction of the route-1 bridge (`mapping.md` //! The server direction of the route-1 bridge (`mapping.md`
//! §Channel-type rows, the `forwarded-tcpip` row + §Global-request //! §Channel-type rows, the `forwarded-tcpip` row + §Global-request
//! rows). Per the family door semantics: the door owns nothing local //! rows). Per the family door semantics: the door owns nothing local,
//! — **the fabric listens**; per arrival the data flows back toward //! **the fabric listens**; per arrival the data flows back toward
//! the vanilla SSH client, which dials its own target. Three moving //! the vanilla SSH client, which dials its own target. Three moving
//! parts, all decided against alktunnels 0.4.1's actual surfaces with //! parts, all decided against alktunnels 0.4.1's actual surfaces with
//! POC-3 §C.4 as the template: //! POC-3 §C.4 as the template (the gate adoption rides 0.5.0's
//! establisher variant, above):
//! //!
//! 1. **The listen dial** (`tcpip_forward` / `streamlocal_forward`): //! 1. **The listen dial** (`tcpip_forward` / `streamlocal_forward`):
//! the door maps the vanilla listen request family-ward as //! the door maps the vanilla listen request family-ward as
//! `TunnelParams { resource: <the wire bind endpoint string>, //! `TunnelParams { resource: <the wire bind endpoint string>,
//! substrate: Tcp/Unix }` — the **resource-naming convention //! substrate: Tcp/Unix }`; the **resource-naming convention
//! recorded** (the one open surface this flow's composition had): //! recorded** (the one open surface this flow's composition had):
//! ADR-001 keeps the wire field a *resource identifier*, and a //! ADR-001 keeps the wire field a *resource identifier*, and a
//! fabric serving vanilla `-R` opt into dynamic-address binding by //! fabric serving vanilla `-R` opt into dynamic-address binding by
//! its own establisher/bind-closure treating the mapped string as //! its own establisher/bind-closure treating the mapped string as
//! its bind endpoint (`bindfirst_establisher` + //! its bind endpoint (`bindfirst_establisher` +
//! `register_openable_with_establisher`, the alkcall ADR-049 //! `register_openable_with_establisher`, the alkcall ADR-049
//! establisher shape — who may bind what is the fabric's ACL + //! establisher shape: who may bind what is the fabric's ACL +
//! its bind closure, assembly policy at the family's provider, //! its bind closure, assembly policy at the family's provider,
//! never the door's). The dial is the published consumer surface — //! never the door's). The dial is the published consumer surface,
//! `TunnelListener::open(client, params)` on the held //! `TunnelListener::open(client, params)` on the held
//! `ChannelClient` — bounded by [`LISTEN_DIAL_TIMEOUT`]; the //! `ChannelClient`, bounded by [`LISTEN_DIAL_TIMEOUT`]; the
//! reply's `bound` + minted `listen` token (the alkcall ADR-049 //! reply's `bound` + minted `listen` token (the alkcall ADR-049
//! amendment-3 establisher fields) are read through //! amendment-3 establisher fields) are read through
//! `parse-listen-reply`-class accessors; **a reply without both is //! `parse-listen-reply`-class accessors; **a reply without both is
//! a declined listen with the fabric listener torn down //! a declined listen with the fabric listener torn down
//! immediately** (never a minted-but-uncorrelatable listen — a //! immediately** (never a minted-but-uncorrelatable listen; a
//! declined listen never left a half state). //! declined listen never left a half state).
//! 2. **The door-side arrival surface** — the composition decision //! 2. **The door-side arrival surface**, the composition decision
//! (the task's second `w3-seam-config` course correction): the //! (the task's second `w3-seam-config` course correction): the
//! fabric dials `tunnel/forwarded` *toward the door* per alktunnels' //! fabric dials `tunnel/forwarded` *toward the door* per alktunnels'
//! bind-first dispatch (`make_bindfirst_accept_handler` + //! bind-first dispatch (`make_bindfirst_accept_handler` +
//! `ForwardedOpener` — the producer calls the op on the channel-0 //! `ForwardedOpener`): the producer calls the op on the channel-0
//! call plane toward the listen opener, and the door reply carries //! call plane toward the listen opener, and the door reply carries
//! the door-allocated channel id the producer adopts; alkcall's //! the door-allocated channel id the producer adopts; alkcall's
//! odd/even split keeps the two open directions collision-free). //! odd/even split keeps the two open directions collision-free).
//! Serving that op needs the door's channel-0 serving loop — an //! Serving that op needs the door's channel-0 serving loop, an
//! alkcall `ChannelClient` built with //! alkcall `ChannelClient` built with
//! `from_connection_with_serving`, whose registry the assembly owns //! `from_connection_with_serving`, whose registry the assembly owns,
//! — so the serve-config carries [`crate::config::TunnelListenSeam`] //! so the serve-config carries [`crate::config::TunnelListenSeam`]
//! (the same `Arc<OperationRegistry>` the assembly wired); alkssh //! (the same `Arc<OperationRegistry>` the assembly wired); alkssh
//! registers the forwarded openable itself in //! registers the forwarded openable itself in
//! [`install_forwarded_surface`] (called from //! [`install_forwarded_surface`] (called from
@@ -82,25 +85,34 @@
//! client's manager + alktunnels' `tunnel_forwarded_spec()` + //! client's manager + alktunnels' `tunnel_forwarded_spec()` +
//! `make_forwarded_op_handler` over the crate's arrival pump, the //! `make_forwarded_op_handler` over the crate's arrival pump, the
//! wire-level ACL (`tunnel:forwarded` scope) and the per-connection //! wire-level ACL (`tunnel:forwarded` scope) and the per-connection
//! channel budget enforced inside alkcall's dispatch. //! channel budget enforced inside alkcall's dispatch, registered
//! 3. **The arrival pump** — the `forwarded-tcpip` data leg: the //! in the **establisher variant** (alktunnels 0.5.0's
//! `register_tunnel_forwarded_openable_with_establisher`, the
//! review-003 W2 adoption): the router's correlation lookup rides
//! the establisher's `ForwardedGate`, so a stale/missing token
//! fails the open typed at the call plane (`channel:open_failed`,
//! `unknown_resource`) before any channel is born; the handler's
//! re-lookup keeps the residual-race +
//! cap-admission teardown-at-birth fallback.
//! 3. **The arrival pump**, the `forwarded-tcpip` data leg: the
//! registered [`ForwardedHandler`]-closure correlates the //! registered [`ForwardedHandler`]-closure correlates the
//! arrival's `listen` token through [`ListenRouter`] (the route was //! arrival's `listen` token through [`ListenRouter`] (the route was
//! registered at listen acceptance with the owning session's //! registered at listen acceptance with the owning session's
//! handle + cap); a miss is **teardown-at-birth** (drop the fabric //! handle + cap); a miss here is the gate-pass residual race,
//! still **teardown-at-birth** (drop the fabric
//! half, never a half-open SSH channel). On a hit, per vanilla `-R` //! half, never a half-open SSH channel). On a hit, per vanilla `-R`
//! convention: the door opens a `forwarded-tcpip` channel *toward //! convention: the door opens a `forwarded-tcpip` channel *toward
//! the client* echoing **the listen request's own args** — the //! the client* echoing **the listen request's own args**, the
//! connected address as the wire `tcpip_forward` request carried it //! connected address as the wire `tcpip_forward` request carried it
//! verbatim (the OpenSSH client matches that exact string; for //! verbatim (the OpenSSH client matches that exact string; for
//! `port = 0` mints the *actually bound* port from the fabric's //! `port = 0` mints the *actually bound* port from the fabric's
//! `bound` reply — the sshd parity recorded in //! `bound` reply, the sshd parity recorded in
//! [`ListenRoute`]) — with the `TunnelForwardedParams.peer` arrival //! [`ListenRoute`]), with the `TunnelForwardedParams.peer` arrival
//! mapped to the originator fields (`HostPort` → address/port; //! mapped to the originator fields (`HostPort` → address/port;
//! `Path` → path + port 0 — the degenerate but typed mapping, //! `Path` → path + port 0, the degenerate but typed mapping,
//! unreachable for a TCP-minted listen). The accepted-or-rejected //! unreachable for a TCP-minted listen). The accepted-or-rejected
//! decision is the in-crate cap (the wave-2 gate's booked acceptor //! decision is the in-crate cap (the wave-2 gate's booked acceptor
//! vocabulary — the per-arrival admission; a client-side rejection //! vocabulary, the per-arrival admission; a client-side rejection
//! or a dead session releases the count, the established channel's //! or a dead session releases the count, the established channel's
//! release rides [`crate::handler`]'s `channel_close` like every //! release rides [`crate::handler`]'s `channel_close` like every
//! established channel). Accept → the fabric `BiStream` pumped //! established channel). Accept → the fabric `BiStream` pumped
@@ -113,7 +125,7 @@
//! //!
//! **The inline listen dial (recorded decision):** the //! **The inline listen dial (recorded decision):** the
//! spawn-and-return rule keeps *serving paths* out of the packet //! spawn-and-return rule keeps *serving paths* out of the packet
//! loop — but the `tcpip_forward` global request's reply is bound to //! loop, but the `tcpip_forward` global request's reply is bound to
//! the hook itself (russh answers the want-reply from the hook's //! the hook itself (russh answers the want-reply from the hook's
//! return + the `&mut u32` out-param; no handle surface responds to a //! return + the `&mut u32` out-param; no handle surface responds to a
//! global request from a spawned task at this pin), so the listen //! global request from a spawned task at this pin), so the listen
@@ -124,18 +136,18 @@
//! packet loop), and its stall is bounded: other channels of the //! packet loop), and its stall is bounded: other channels of the
//! connection pause only for [`LISTEN_DIAL_TIMEOUT`], and the //! connection pause only for [`LISTEN_DIAL_TIMEOUT`], and the
//! fabric-dial failure answers a denied listen. All serving below the //! fabric-dial failure answers a denied listen. All serving below the
//! reply (none — the listen returns) and all arrival paths stay //! reply (none, since the listen returns) and all arrival paths stay
//! spawned. //! spawned.
//! //!
//! ## Bounded bookkeeping (AGENTS.md convention 9) //! ## Bounded bookkeeping (AGENTS.md convention 9)
//! //!
//! - **The per-session listener map** (the session-handler's //! - **The per-session listener map** (the session-handler's
//! `listeners` — packet-loop-only writer, like the `channels`/ //! `listeners`, packet-loop-only writer, like the `channels`/
//! `ptys` stores) maps the minted token → the pending listen (wire //! `ptys` stores) maps the minted token → the pending listen (wire
//! args, bound port, the [`alktunnels::consumer::TunnelListener`] //! args, bound port, the [`alktunnels::consumer::TunnelListener`]
//! whose `close`/`Drop` perform the fabric teardown). Its bound: //! whose `close`/`Drop` perform the fabric teardown). Its bound:
//! the listen count shares the `Limits::max_concurrent_channels` //! the listen count shares the `Limits::max_concurrent_channels`
//! *ceiling* in a dedicated counter — a listen is at least one //! *ceiling* in a dedicated counter; a listen is at least one
//! future channel's potential, sessions minting unbounded listeners //! future channel's potential, sessions minting unbounded listeners
//! would mint an unbounded fabric-channel map on the shared leg. //! would mint an unbounded fabric-channel map on the shared leg.
//! - **The route map** ([`ListenRouter`], shared with the registered //! - **The route map** ([`ListenRouter`], shared with the registered
@@ -143,29 +155,29 @@
//! bounded by construction: entries are minted exactly at accepted //! bounded by construction: entries are minted exactly at accepted
//! listens (listener-cap gated), removed 1:1 at the cancel forms //! listens (listener-cap gated), removed 1:1 at the cancel forms
//! and at the owning session's teardown (the handler's `Drop` //! and at the owning session's teardown (the handler's `Drop`
//! removes its tokens — a stale route to a dead handle only ever //! removes its tokens; a stale route to a dead handle only ever
//! costs one failed open that returns teardown-at-birth, never a //! costs one failed open that returns teardown-at-birth, never a
//! half-open SSH channel). //! half-open SSH channel).
//! - **A cancelled / torn-down listen never half-serves**: the cancel //! - **A cancelled / torn-down listen never half-serves**: the cancel
//! path removes the route before any teardown (a racing arrival //! path removes the route before any teardown (a racing arrival
//! misses → teardown-at-birth), and `cancel_*` of an unknown //! misses → teardown-at-birth), and `cancel_*` of an unknown
//! (address, bound-port) pair answers `false` — the vanilla //! (address, bound-port) pair answers `false`, the vanilla
//! cancel contract (sshd parity: match by the raw wire string + //! cancel contract (sshd parity: match by the raw wire string +
//! the bound port). One recorded caveat: an arrival that *cloned* //! the bound port). One recorded caveat: an arrival that *cloned*
//! its route just before the cancel removes it still completes its //! its route just before the cancel removes it still completes its
//! open + pump (the forwarded channel is independent of the //! open + pump (the forwarded channel is independent of the
//! listener's teardown — the arrival is never half-open, but a //! listener's teardown; the arrival is never half-open, but a
//! cancelled listen can deliver one already-accepted connection; //! cancelled listen can deliver one already-accepted connection;
//! sshd parity). //! sshd parity).
//! //!
//! ## The runtime-Unix gate (OQ-11) //! ## The runtime-Unix gate (OQ-11)
//! //!
//! `direct-streamlocal` compiles wherever `tunnel` compiles (universal //! `direct-streamlocal` compiles wherever `tunnel` compiles (universal
//! compile — no per-platform feature fork); **serving is runtime-Unix- //! compile, no per-platform feature fork); **serving is runtime-Unix-
//! gated**: off-Unix a clean open-decline **before any fabric call** //! gated**: off-Unix a clean open-decline **before any fabric call**
//! (never a partial bridge), on-Unix the real bridge. The gate lives //! (never a partial bridge), on-Unix the real bridge. The gate lives
//! in the dispatch hook ([`crate::handler`]'s `direct-streamlocal` //! in the dispatch hook ([`crate::handler`]'s `direct-streamlocal`
//! row), before the cap admission — a platform-refused open consumes //! row), before the cap admission; a platform-refused open consumes
//! nothing. The gate rides the `streamlocal_forward` listen row the //! nothing. The gate rides the `streamlocal_forward` listen row the
//! same way (`w3-tunnel-listen`). //! same way (`w3-tunnel-listen`).
//! //!
@@ -173,25 +185,25 @@
//! //!
//! The resolved SSH identity is the auth bridge's ride-along in //! The resolved SSH identity is the auth bridge's ride-along in
//! handler state (`auth_publickey` stores; the row dispatches never //! handler state (`auth_publickey` stores; the row dispatches never
//! serve anonymously — the `no-identity` guard, unreachable on the //! serve anonymously, the `no-identity` guard, unreachable on the
//! wire because russh authorizes before channels exist, fail-closed //! wire because russh authorizes before channels exist, fail-closed
//! like the git/tty rows' `w2-auth-bridge` parked item discharged //! like the git/tty rows' `w2-auth-bridge` parked item discharged
//! there). The guard gates every tunnel open **at the door**; the //! there). The guard gates every tunnel open **at the door**; the
//! open that proceeds then rides the channels connection into the //! open that proceeds then rides the channels connection into the
//! fabric, whose own `AccessControl::check(peer_identity)` gates the //! fabric, whose own `AccessControl::check(peer_identity)` gates the
//! mapped `channels/tunnel/direct` open (alkcall CF-005/CF-006) — //! mapped `channels/tunnel/direct` open (alkcall CF-005/CF-006);
//! the composed-gate witness is the harness's identity rows. The //! the composed-gate witness is the harness's identity rows. The
//! `-R` leg composes the same gates on both of its directions: the //! `-R` leg composes the same gates on both of its directions: the
//! listen dial rides the fabric leg's identity (`tunnel:open` scope — //! listen dial rides the fabric leg's identity (`tunnel:open` scope,
//! the assembly's dial-identity composition, unchanged), and the //! the assembly's dial-identity composition, unchanged), and the
//! arrivals are gated by the door-side registry's ACL //! arrivals are gated by the door-side registry's ACL
//! (`tunnel:forwarded` scope, per arrival) exactly when the assembly's //! (`tunnel:forwarded` scope, per arrival) exactly when the assembly's
//! fabric leg carries it. //! fabric leg carries it.
//! //!
//! **`set_identity` mechanics (recorded — `w3-tunnel-direct`'s decided //! **`set_identity` mechanics (recorded: `w3-tunnel-direct`'s decided
//! point, carried unchanged):** the alkhub `hub_leg.rs` one-time-set //! point, carried unchanged):** the alkhub `hub_leg.rs` one-time-set
//! precedent (`channel0_conn.set_identity(identity)`) is a //! precedent (`channel0_conn.set_identity(identity)`) is a
//! **serving-side** set — the identity the fabric leg dials with, and //! **serving-side** set: the identity the fabric leg dials with, and
//! against which the fabric's ACL gates alkssh's mapped opens, is the //! against which the fabric's ACL gates alkssh's mapped opens, is the
//! assembly-supplied `ChannelClient`'s own dial identity (CF-005: set //! assembly-supplied `ChannelClient`'s own dial identity (CF-005: set
//! once at dial, propagated to channel 0 at `from_connection`); the //! once at dial, propagated to channel 0 at `from_connection`); the
@@ -216,7 +228,10 @@ use alkcall::core::auth::AuthContext;
use alkcall::core::types::BiStream; use alkcall::core::types::BiStream;
use alktunnels::consumer::{TunnelListener, TunnelSession}; use alktunnels::consumer::{TunnelListener, TunnelSession};
use alktunnels::params::{Substrate, SubstrateAddr, TunnelForwardedParams, TunnelParams}; use alktunnels::params::{Substrate, SubstrateAddr, TunnelForwardedParams, TunnelParams};
use alktunnels::producer::{ForwardedHandler, register_tunnel_forwarded_openable}; use alktunnels::producer::{
ForwardedGate, ForwardedHandler, TunnelEstablishError,
register_tunnel_forwarded_openable_with_establisher,
};
use parking_lot::Mutex; use parking_lot::Mutex;
use russh::Channel; use russh::Channel;
use russh::server::{ChannelOpenHandle, Handle}; use russh::server::{ChannelOpenHandle, Handle};
@@ -231,10 +246,10 @@ use crate::{config, dispatch, observe};
pub(crate) type WireChannel = Channel<russh::server::Msg>; pub(crate) type WireChannel = Channel<russh::server::Msg>;
/// The spawned route-1 bridge (`w3-tunnel-direct`, the POC-3 §C /// The spawned route-1 bridge (`w3-tunnel-direct`, the POC-3 §C
/// recipe): the fabric open runs here — accept only after it succeeds, /// recipe): the fabric open runs here; accept only after it succeeds,
/// `take_halves()` pumped against the taken channel on completion, and /// `take_halves()` pumped against the taken channel on completion, and
/// on failure the explicit `ConnectFailed` reject **with the cap count /// on failure the explicit `ConnectFailed` reject **with the cap count
/// released** (the recorded release mechanism — the admitted open this /// released** (the recorded release mechanism: the admitted open this
/// task holds was never established, so no `channel_close` can ever /// task holds was never established, so no `channel_close` can ever
/// fire; the count recovers here, in the task that knows the failure, /// fire; the count recovers here, in the task that knows the failure,
/// exactly once: a success never releases). The channel closes on /// exactly once: a success never releases). The channel closes on
@@ -268,7 +283,7 @@ pub(crate) async fn serve_direct_row(
} }
/// The open hook's spawn step (ADR-006 decision 4): the handler clones /// The open hook's spawn step (ADR-006 decision 4): the handler clones
/// the cap `Arc` (the failure-path release's handle — the count was /// the cap `Arc` (the failure-path release's handle; the count was
/// admitted at the hook and is held across the fabric open) and hands /// admitted at the hook and is held across the fabric open) and hands
/// the rest over; the hook returns fast with the fabric open in /// the rest over; the hook returns fast with the fabric open in
/// flight. /// flight.
@@ -292,29 +307,29 @@ pub(crate) fn spawn_direct_row(
/// The wall-clock bound on the family-ward listen dial (`TunnelListener:: /// The wall-clock bound on the family-ward listen dial (`TunnelListener::
/// open`): the fabric's establisher is already bound by alkcall's own /// open`): the fabric's establisher is already bound by alkcall's own
/// establisher deadline (10 s, alkcall ADR-049 §2), so this matches it — /// establisher deadline (10 s, alkcall ADR-049 §2), so this matches it,
/// a fabric that never answers a listen dial cannot hold the packet /// a fabric that never answers a listen dial cannot hold the packet
/// loop longer than a bounded dial. /// loop longer than a bounded dial.
pub(crate) const LISTEN_DIAL_TIMEOUT: Duration = Duration::from_secs(10); pub(crate) const LISTEN_DIAL_TIMEOUT: Duration = Duration::from_secs(10);
/// One route in the listen-arrival bookkeeping — registered at listen /// One route in the listen-arrival bookkeeping, registered at listen
/// acceptance, consulted per arrival. The echo fields are the vanilla /// acceptance, consulted per arrival. The echo fields are the vanilla
/// `-R` convention decoded (sshd parity, recorded on [`ListenRouter`]): /// `-R` convention decoded (sshd parity, recorded on [`ListenRouter`]):
/// the connected address rides the wire `tcpip_forward` string /// the connected address rides the wire `tcpip_forward` string
/// **verbatim** (the OpenSSH client sent that exact string and matches /// **verbatim** (the OpenSSH client sent that exact string and matches
/// the open's address field against it exactly), the port is the /// the open's address field against it exactly), the port is the
/// **actually bound** one (the fabric's `bound` reply — the sshd /// **actually bound** one (the fabric's `bound` reply, the sshd
/// `get_local_port`-of-accepted-socket parity, which is what makes /// `get_local_port`-of-accepted-socket parity, which is what makes
/// `port = 0` mints round-trip), and the unix path rides the /// `port = 0` mints round-trip), and the unix path rides the
/// `streamlocal_forward` request verbatim. /// `streamlocal_forward` request verbatim.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct ListenRoute { pub(crate) struct ListenRoute {
/// The SSH args the arrivals' opens echo — keyed by the route's /// The SSH args the arrivals' opens echo, keyed by the route's
/// substrate (`Tcp`/`Udp` → the vanilla `-R` trio's first two /// substrate (`Tcp`/`Udp` → the vanilla `-R` trio's first two
/// fields; `Unix` → the `forwarded-streamlocal` path). /// fields; `Unix` → the `forwarded-streamlocal` path).
pub(crate) echo: RouteEcho, pub(crate) echo: RouteEcho,
pub(crate) substrate: Substrate, pub(crate) substrate: Substrate,
/// The owning session's packet-loop handle — the arrival's SSH /// The owning session's packet-loop handle: the arrival's SSH
/// channel opens through it; a session that died fails the open /// channel opens through it; a session that died fails the open
/// (the arrival's teardown-at-birth path). /// (the arrival's teardown-at-birth path).
pub(crate) handle: Arc<Handle>, pub(crate) handle: Arc<Handle>,
@@ -340,10 +355,10 @@ pub(crate) enum RouteEcho {
/// `Drop`). A mint on an already-held token is refused fail-closed /// `Drop`). A mint on an already-held token is refused fail-closed
/// ([`ListenRouter::register`], `w4-router-collision`). Arrivals /// ([`ListenRouter::register`], `w4-router-collision`). Arrivals
/// that find no route are the correlation-miss /// that find no route are the correlation-miss
/// class — teardown-at-birth, never a half-open SSH channel. /// class (teardown-at-birth, never a half-open SSH channel).
/// ///
/// The map is behind a `parking_lot::Mutex` because arrivals arrive on /// The map is behind a `parking_lot::Mutex` because arrivals arrive on
/// alkcall serving tasks while the packet loop mints/removes — the /// alkcall serving tasks while the packet loop mints/removes; the
/// held region is a HashMap op, never an await. /// held region is a HashMap op, never an await.
pub(crate) struct ListenRouter { pub(crate) struct ListenRouter {
routes: Mutex<HashMap<u32, ListenRoute>>, routes: Mutex<HashMap<u32, ListenRoute>>,
@@ -352,21 +367,21 @@ pub(crate) struct ListenRouter {
impl ListenRouter { impl ListenRouter {
/// Minted-entry registration (the listen-acceptance step), /// Minted-entry registration (the listen-acceptance step),
/// fail-closed on token collision (the wave-3 gate's booked item /// fail-closed on token collision (the wave-3 gate's booked item
/// 4, `w4-router-collision` — the same posture the same gate /// 4, `w4-router-collision`, the same posture the same gate
/// landed inline for a duplicate `tunnel/forwarded` /// landed inline for a duplicate `tunnel/forwarded`
/// registration): safety rests on the fabric's ADR-008 mint /// registration): safety rests on the fabric's ADR-008 mint
/// discipline upstream, and this defends in depth against a /// discipline upstream, and this defends in depth against a
/// collided mint — a `register` on an already-held token returns /// collided mint, a `register` on an already-held token returns
/// `Err` and **never replaces** the held route, so a colliding /// `Err` and **never replaces** the held route, so a colliding
/// mint can never steal a live adapter's arrival routes. The /// mint can never steal a live adapter's arrival routes. The
/// caller declines the listen (the fabric listener is then torn /// caller declines the listen (the fabric listener is then torn
/// down — a refused registration never left a half state). /// down; a refused registration never left a half state).
/// ///
/// Error surface (decided, `w4-router-collision`): the refusal /// Error surface (decided, `w4-router-collision`): the refusal
/// rides [`String`]-carried detail surfaced as the request-time /// rides [`String`]-carried detail surfaced as the request-time
/// **decline disposition** (`RequestDenied`, reason /// **decline disposition** (`RequestDenied`, reason
/// `token-collision`), not a distinct [`AdapterError`](crate::AdapterError) /// `token-collision`), not a distinct [`AdapterError`](crate::AdapterError)
/// variant — a global-request hook's `Err` would kill the whole /// variant, since a global-request hook's `Err` would kill the whole
/// russh session (russh 0.65 answers the decline from the hook's /// russh session (russh 0.65 answers the decline from the hook's
/// `Ok(false)`, and `Err` propagates out of the packet loop), and /// `Ok(false)`, and `Err` propagates out of the packet loop), and
/// the taxonomy's rule holds that wire decline reasons are never /// the taxonomy's rule holds that wire decline reasons are never
@@ -378,7 +393,7 @@ impl ListenRouter {
let mut routes = self.routes.lock(); let mut routes = self.routes.lock();
if routes.contains_key(&token) { if routes.contains_key(&token) {
return Err(format!( return Err(format!(
"listen token {token} is already routed — a collided \ "listen token {token} is already routed; a collided \
mint may not replace a live adapter's route" mint may not replace a live adapter's route"
)); ));
} }
@@ -386,7 +401,7 @@ impl ListenRouter {
Ok(()) Ok(())
} }
/// The per-arrival lookup — cloned out, never taken: one route /// The per-arrival lookup, cloned out, never taken: one route
/// serves unboundedly many arrivals; only the cancel forms and /// serves unboundedly many arrivals; only the cancel forms and
/// session teardown remove. /// session teardown remove.
pub(crate) fn route_for(&self, token: u32) -> Option<ListenRoute> { pub(crate) fn route_for(&self, token: u32) -> Option<ListenRoute> {
@@ -394,7 +409,7 @@ impl ListenRouter {
} }
/// The cancel-form removal (1:1 into the family listener's /// The cancel-form removal (1:1 into the family listener's
/// teardown): removes first, so a racing arrival already misses — /// teardown): removes first, so a racing arrival already misses,
/// teardown-at-birth, never a post-cancel half-serve. /// teardown-at-birth, never a post-cancel half-serve.
pub(crate) fn cancel(&self, token: &u32) -> bool { pub(crate) fn cancel(&self, token: &u32) -> bool {
self.routes.lock().remove(token).is_some() self.routes.lock().remove(token).is_some()
@@ -402,7 +417,7 @@ impl ListenRouter {
/// The owning session's teardown (the handler's `Drop`): remove /// The owning session's teardown (the handler's `Drop`): remove
/// the session's tokens; any listener objects left drop with the /// the session's tokens; any listener objects left drop with the
/// handler (the fabric-side reap — see `PendingListen`). /// handler (the fabric-side reap, see `PendingListen`).
pub(crate) fn remove_tokens(&self, tokens: &[u32]) { pub(crate) fn remove_tokens(&self, tokens: &[u32]) {
let mut routes = self.routes.lock(); let mut routes = self.routes.lock();
for token in tokens { for token in tokens {
@@ -423,24 +438,42 @@ impl Default for ListenRouter {
/// [`crate::SshAdapter::new`] when the fabric leg + seam are both /// [`crate::SshAdapter::new`] when the fabric leg + seam are both
/// present): builds the router, registers alktunnels' forwarded /// present): builds the router, registers alktunnels' forwarded
/// openable into the seam's shared registry over a `ChannelCore` /// openable into the seam's shared registry over a `ChannelCore`
/// wrapping a clone of the held `ChannelClient`'s manager — the /// wrapping a clone of the held `ChannelClient`'s manager, the
/// alkcall shape whose wrapper performs the door-side arrival's /// alkcall shape whose wrapper performs the door-side arrival's
/// channel allocation, ACL (`tunnel:forwarded`, per call) and budget /// channel allocation, ACL (`tunnel:forwarded`, per call) and budget
/// (alkcall's secure default per-identity policy, `256`, plus the /// (alkcall's secure default per-identity policy, `256`, plus the
/// `DEFAULT_MAX_CHANNELS` per-connection bound — alkssh composes no /// `DEFAULT_MAX_CHANNELS` per-connection bound; alkssh composes no
/// deployment policy; an assembly wanting a different per-identity /// deployment policy; an assembly wanting a different per-identity
/// cap shapes it through its own `ServingConfig`/`registry` /// cap shapes it through its own `ServingConfig`/`registry`
/// composition upstream) — and spawns alktunnels' forward-op handler /// composition upstream), and spawns alktunnels' forward-op handler
/// over the crate's arrival pump. The registration is /// over the crate's arrival pump.
///
/// The registration shape is the **establisher variant**
/// (`register_tunnel_forwarded_openable_with_establisher`, alktunnels
/// 0.5.0, review 003 W2's adoption, ADR-008 Amendment 2's recommended
/// form): the router's correlation lookup rides a [`ForwardedGate`]
/// capturing the router, so a stale/missing `listen` token fails the
/// open at alkcall's call plane as a typed `channel:open_failed`
/// (`details.reason == "unknown_resource"`) **before any channel is
/// born**; the phantom-channel class (call-plane success, then
/// handler-side decline) is closed at the open; door-side arrival
/// correlations that pass the gate but lose the narrow gate-pass→
/// handler-run race, plus every cap-admission decline, still take the
/// handler's teardown-at-birth path (the residual-race posture the
/// base registration also carries). A malformed arrival is likewise
/// typed (`handler_error`) instead of the base shape's
/// success-then-silent-birth-teardown.
///
/// The registration is
/// one-per-op-id-per-registry: alkcall's `register` silently /// one-per-op-id-per-registry: alkcall's `register` silently
/// replaces an existing entry, so the door **pre-checks the op** — /// replaces an existing entry, so the door **pre-checks the op**:
/// a `tunnel/forwarded` registration that already exists (e.g. a /// a `tunnel/forwarded` registration that already exists (e.g. a
/// second adapter over the same shared registry) is a failed /// second adapter over the same shared registry) is a failed
/// construction (fail-closed — never a route to a handler the door /// construction (fail-closed; never a route to a handler the door
/// does not own). /// does not own).
/// ///
/// On success the router rides back into the serve-config (the /// On success the router rides back into the serve-config (the
/// session handlers read it) — `Ok` with the router installed, `Err` /// session handlers read it): `Ok` with the router installed, `Err`
/// is the caller's constructor failure. /// is the caller's constructor failure.
pub(crate) fn install_forwarded_surface(serve: &mut config::ServeConfig) -> Result<(), String> { pub(crate) fn install_forwarded_surface(serve: &mut config::ServeConfig) -> Result<(), String> {
let Some(seam) = serve.tunnel_listen_seam.as_mut() else { let Some(seam) = serve.tunnel_listen_seam.as_mut() else {
@@ -450,6 +483,21 @@ pub(crate) fn install_forwarded_surface(serve: &mut config::ServeConfig) -> Resu
return Err("tunnel listen seam configured without a fabric leg".to_string()); return Err("tunnel listen seam configured without a fabric leg".to_string());
}; };
let router = Arc::new(ListenRouter::default()); let router = Arc::new(ListenRouter::default());
let gate: ForwardedGate = {
let router = Arc::clone(&router);
Arc::new(move |params: &TunnelForwardedParams| {
if router.route_for(params.listen).is_some() {
Ok(())
} else {
Err(TunnelEstablishError::UnknownResource(format!(
"no accepted listen routes the arrival's token {}, \
the forwarding session is gone or was never minted \
(alkssh: stale/missing `-R` token)",
params.listen
)))
}
})
};
let handler: ForwardedHandler = { let handler: ForwardedHandler = {
let router = Arc::clone(&router); let router = Arc::clone(&router);
Arc::new(move |params: TunnelForwardedParams, bidi: BiStream| { Arc::new(move |params: TunnelForwardedParams, bidi: BiStream| {
@@ -467,15 +515,16 @@ pub(crate) fn install_forwarded_surface(serve: &mut config::ServeConfig) -> Resu
{ {
return Err(format!( return Err(format!(
"the tunnel/forwarded op ({}) is already registered in the \ "the tunnel/forwarded op ({}) is already registered in the \
seam's registry — a conflicting registration would silently \ seam's registry; a conflicting registration would silently \
replace the door's arrival route", replace the door's arrival route",
alktunnels::params::OP_TUNNEL_FORWARDED alktunnels::params::OP_TUNNEL_FORWARDED
)); ));
} }
register_tunnel_forwarded_openable( register_tunnel_forwarded_openable_with_establisher(
&core, &core,
&seam.registry, &seam.registry,
AuthContext::anonymous(b"alk/tunnel"), AuthContext::anonymous(b"alk/tunnel"),
gate,
handler, handler,
) )
.map_err(|failure| format!("tunnel/forwarded openable registration: {failure}"))?; .map_err(|failure| format!("tunnel/forwarded openable registration: {failure}"))?;
@@ -483,29 +532,34 @@ pub(crate) fn install_forwarded_surface(serve: &mut config::ServeConfig) -> Resu
Ok(()) Ok(())
} }
/// The registered arrival pump — the `forwarded-tcpip` data leg /// The registered arrival pump, the `forwarded-tcpip` data leg
/// (mapping.md's row, "hand the channel duplex to the handler"): the /// (mapping.md's row, "hand the channel duplex to the handler"): the
/// alkcall openable registration hands every fabric arrival here with /// alkcall openable registration hands every fabric arrival here with
/// the parsed [`TunnelForwardedParams`] and the arrival's `BiStream`; /// the parsed [`TunnelForwardedParams`] and the arrival's `BiStream`;
/// the flow is the recorded shape: /// the flow is the recorded shape:
/// ///
/// 1. **Correlation** — the route for the arrival's minted token; /// 1. **Correlation**: the route for the arrival's minted token;
/// a miss is teardown-at-birth (drop the fabric half; the alkcall /// a miss is teardown-at-birth (drop the fabric half; the alkcall
/// wrapper's awaited join ends, the channel reaps). /// wrapper's awaited join ends, the channel reaps). The gate
/// (the establisher's call-plane correlation) misses a stale
/// token before any channel is born; this handler-side re-lookup
/// is the narrow gate-pass→handler-run **residual race**, the
/// same miss class, past the open, and the never-established cap
/// declines below take the same path.
/// 2. **The in-crate cap admission** (bounded resources at the accept /// 2. **The in-crate cap admission** (bounded resources at the accept
/// surface — the wave-2 gate's booked acceptor vocabulary): no /// surface, the wave-2 gate's booked acceptor vocabulary): no
/// room → the fabric half drops, the arrival never opens an SSH /// room → the fabric half drops, the arrival never opens an SSH
/// channel. The count releases here on every never-established /// channel. The count releases here on every never-established
/// path; an established one releases at its close (the packet /// path; an established one releases at its close (the packet
/// loop's release site), the direct rows' disjoint-release shape. /// loop's release site), the direct rows' disjoint-release shape.
/// 3. **The SSH open toward the client** — the echoed listen args /// 3. **The SSH open toward the client**, the echoed listen args
/// (`forwarded-tcpip` for the tcp/udp substrates with the originator /// (`forwarded-tcpip` for the tcp/udp substrates with the originator
/// fields mapped from `TunnelForwardedParams.peer`; `forwarded- /// fields mapped from `TunnelForwardedParams.peer`; `forwarded-
/// streamlocal@openssh.com` with the echoed path for `Unix`) — per /// streamlocal@openssh.com` with the echoed path for `Unix`); per
/// the spawn-and-return constraint this runs on this spawned task; /// the spawn-and-return constraint this runs on this spawned task;
/// a rejected/dead-open releases the count and drops the fabric /// a rejected/dead-open releases the count and drops the fabric
/// half (never a half-open SSH channel). /// half (never a half-open SSH channel).
/// 4. **The pump** — the fabric `BiStream` ↔ the SSH channel duplex /// 4. **The pump**: the fabric `BiStream` ↔ the SSH channel duplex
/// (the route-1 bridge's primitive, reversed). /// (the route-1 bridge's primitive, reversed).
async fn pump_forwarded_arrival( async fn pump_forwarded_arrival(
router: Arc<ListenRouter>, router: Arc<ListenRouter>,
@@ -555,7 +609,7 @@ async fn pump_forwarded_arrival(
} }
(RouteEcho::Tcp { address, port }, SubstrateAddr::Path { path }) => { (RouteEcho::Tcp { address, port }, SubstrateAddr::Path { path }) => {
// The degenerate-but-typed mapping (a path-shaped peer on // The degenerate-but-typed mapping (a path-shaped peer on
// a tcp listen — unreachable from a well-behaved producer): // a tcp listen, unreachable from a well-behaved producer):
// the path rides the originator string, port 0. // the path rides the originator string, port 0.
route route
.handle .handle
@@ -584,12 +638,12 @@ async fn pump_forwarded_arrival(
} }
} }
/// A minted listen — the door-side outcome of a `tcpip_forward` / /// A minted listen, the door-side outcome of a `tcpip_forward` /
/// `streamlocal_forward` request's family-ward dial (the published /// `streamlocal_forward` request's family-ward dial (the published
/// `TunnelListener::open`, bounded by [`LISTEN_DIAL_TIMEOUT`], with /// `TunnelListener::open`, bounded by [`LISTEN_DIAL_TIMEOUT`], with
/// the reply's `bound` + `listen` establisher fields required: a /// the reply's `bound` + `listen` establisher fields required: a
/// producer that minted no correlation token or no bound report /// producer that minted no correlation token or no bound report
/// cannot serve — [`Result`]'s `Err` declines the SSH request before /// cannot serve; [`Result`]'s `Err` declines the SSH request before
/// anything registers). /// anything registers).
pub(crate) struct MintedListen { pub(crate) struct MintedListen {
pub(crate) listener: TunnelListener, pub(crate) listener: TunnelListener,
@@ -599,9 +653,9 @@ pub(crate) struct MintedListen {
/// The listen dial (the mapping.md §Global-request rows' family-ward /// The listen dial (the mapping.md §Global-request rows' family-ward
/// step): `TunnelParams { resource: <endpoint string>, substrate }` /// step): `TunnelParams { resource: <endpoint string>, substrate }`
/// — the wire-named bind endpoint as the resource, the recorded /// : the wire-named bind endpoint as the resource, the recorded
/// door-side naming convention whose fabric-side interpretation is /// door-side naming convention whose fabric-side interpretation is
/// the assembly's establisher — then the reply projection. Called /// the assembly's establisher; then the reply projection. Called
/// inline from the handler hook (see the recorded inline-decision in /// inline from the handler hook (see the recorded inline-decision in
/// the module doc). /// the module doc).
pub(crate) async fn open_listen( pub(crate) async fn open_listen(
@@ -636,7 +690,7 @@ pub(crate) async fn open_listen(
/// The bound port the listen request's out-param carries /// The bound port the listen request's out-param carries
/// (the `&mut u32` sshd parity): the fabric's bound report's port. /// (the `&mut u32` sshd parity): the fabric's bound report's port.
/// Unix listens have no port semantics — the tcp variants only. /// Unix listens have no port semantics; the tcp variants only.
pub(crate) fn bound_port(bound: &SubstrateAddr) -> Option<u32> { pub(crate) fn bound_port(bound: &SubstrateAddr) -> Option<u32> {
match bound { match bound {
SubstrateAddr::HostPort { port, .. } => Some(u32::from(*port)), SubstrateAddr::HostPort { port, .. } => Some(u32::from(*port)),
@@ -644,12 +698,12 @@ pub(crate) fn bound_port(bound: &SubstrateAddr) -> Option<u32> {
} }
} }
/// The family-ward resource string for a listen request — the wire /// The family-ward resource string for a listen request: the wire
/// bind endpoint verbatim (`"localhost"` / `"0.0.0.0"` / explicit /// bind endpoint verbatim (`"localhost"` / `"0.0.0.0"` / explicit
/// host: the OpenSSH client has already normalized the no-address case /// host: the OpenSSH client has already normalized the no-address case
/// to `"localhost"` and `""` before this surface exists — sshd /// to `"localhost"` and `""` before this surface exists; sshd
/// `channel_rfwd_bind_host` parity), with the requested port for the /// `channel_rfwd_bind_host` parity), with the requested port for the
/// tcp substrates (`host:port`; port `0` stays `0` — the OS-picking is /// tcp substrates (`host:port`; port `0` stays `0`, since the OS-picking is
/// the fabric's bind step, whose bound report feeds the out-param). /// the fabric's bind step, whose bound report feeds the out-param).
pub(crate) fn listen_resource(endpoint: &str, substrate: Substrate, port: Option<u32>) -> String { pub(crate) fn listen_resource(endpoint: &str, substrate: Substrate, port: Option<u32>) -> String {
match substrate { match substrate {
@@ -661,9 +715,9 @@ pub(crate) fn listen_resource(endpoint: &str, substrate: Substrate, port: Option
/// The per-session pending listen (the packet-loop-only store, like /// The per-session pending listen (the packet-loop-only store, like
/// the `channels`/`ptys` stores): the minted token is the map's key; /// the `channels`/`ptys` stores): the minted token is the map's key;
/// the entry carries what the cancel forms match on (the wire endpoint /// the entry carries what the cancel forms match on (the wire endpoint
/// verbatim + the bound port — the sshd `c->path == host && /// verbatim + the bound port, the sshd `c->path == host &&
/// c->listening_port == port` parity) over the substrate (a unix /// c->listening_port == port` parity) over the substrate (a unix
/// entry's port is 0-carrying and never matched — the unix cancel /// entry's port is 0-carrying and never matched; the unix cancel
/// forms match on the path alone) and the session-owned /// forms match on the path alone) and the session-owned
/// [`TunnelListener`] whose close/Drop performs the family teardown. /// [`TunnelListener`] whose close/Drop performs the family teardown.
pub(crate) struct PendingListen { pub(crate) struct PendingListen {
@@ -673,16 +727,65 @@ pub(crate) struct PendingListen {
pub(crate) listener: TunnelListener, pub(crate) listener: TunnelListener,
} }
/// The listen count's dedicated bound — the same mechanism as /// The listen count's dedicated bound, the same mechanism as
/// [`ChannelCap`], instantiated per session next to it, against the /// [`ChannelCap`], instantiated per session next to it, against the
/// same `Limits::max_concurrent_channels` ceiling: listeners and /// same `Limits::max_concurrent_channels` ceiling: listeners and
/// channels stay independently bounded while their mix stays /// channels stay independently bounded while their mix stays
/// unconstrained (AGENTS.md convention 9 — no unbounded listener /// unconstrained (AGENTS.md convention 9: no unbounded listener
/// map). Admits are packet-loop-only (the listen hooks are the only /// map). Admits are packet-loop-only (the listen hooks are the only
/// mint site); releases fire at the cancel forms, the listen-dial /// mint site); releases fire at the cancel forms, the listen-dial
/// failures (admitted-but-never-minted — the route 1 bridge's /// failures (admitted-but-never-minted, the route 1 bridge's
/// release-site shape), and nowhere else (a minted listener's release /// release-site shape), and nowhere else (a minted listener's release
/// is... never — listeners hold until cancel or session teardown, so /// is... never: listeners hold until cancel or session teardown, so
/// the session's count dies with the handler, per the channels /// the session's count dies with the handler, per the channels
/// cap's same posture). /// cap's same posture).
pub(crate) type ListenerCap = ChannelCap; pub(crate) type ListenerCap = ChannelCap;
#[cfg(test)]
mod tests {
use alktunnels::params::{Substrate, SubstrateAddr, TunnelForwardedParams};
use super::{ListenRouter, pump_forwarded_arrival};
/// The arrival pump's handler-side miss (the narrow gate-pass→
/// handler-run residual race the establisher shape retains) still
/// teardown-at-births: a token with no route at handler time drops
/// the fabric half; the adoptee reads EOF, never a half-served
/// arrival.
#[tokio::test]
async fn the_pump_residual_miss_teardown_at_births_the_fabric_half() {
let router = ListenRouter::default();
let (door, fabric_half) = tokio::io::duplex(64 * 1024);
let (reader, writer) = tokio::io::split(door);
let bidi = alkcall::core::types::BiStream::from_joined(reader, writer);
let pump = pump_forwarded_arrival(
std::sync::Arc::new(router),
TunnelForwardedParams {
listen: 424_242,
substrate: Substrate::Tcp,
peer: SubstrateAddr::HostPort {
host: "127.0.0.1".to_owned(),
port: 1234,
},
},
bidi,
);
tokio::time::timeout(std::time::Duration::from_secs(10), pump)
.await
.expect("the miss returns in time (the pump drops the half and ends)");
let mut adoptee = fabric_half;
let read = tokio::time::timeout(
std::time::Duration::from_secs(10),
tokio::io::AsyncReadExt::read_u8(&mut adoptee),
)
.await
.expect("the adoptee's EOF is observable in time");
assert!(
read.is_err(),
"the adopted fabric half EOFs (teardown-at-birth, not a half-serve)"
);
}
}
@@ -1,6 +1,6 @@
--- ---
id: architecture/oq-01-relay-leg-consumer id: architecture/oq-01-relay-leg-consumer
name: "OQ-01 tracker — alk/ssh relay/browser-leg consumer" name: "OQ-01 tracker: alk/ssh relay/browser-leg consumer"
status: pending status: pending
depends_on: [] depends_on: []
scope: single scope: single
@@ -12,11 +12,11 @@ tags: [deferred-oq, external-trigger]
## Description ## Description
External-trigger tracker for [OQ-01](docs/architecture/open-questions.md) — "does an `alk/ssh` relay/browser-leg consumer emerge?" (deferred(scope)). Not actionable work; tracks whether the unblocking condition has arrived. External-trigger tracker for [OQ-01](docs/architecture/open-questions.md): "does an `alk/ssh` relay/browser-leg consumer emerge?" (deferred(scope)). Not actionable work; it tracks whether the unblocking condition has arrived.
## Work ## Work
Transition check only: when a concrete consumer use case exists — a browser-WASM route landing in an assembly (alkcall ADR-009 sketch) or a hub deployment needing SSH-in-relay — mark this task `completed` and move OQ-01 from `deferred(scope)` to `open` in `open-questions.md` (directly to `resolved` if no decision remains, which would be an ADR-001 amendment since the pin is one-way). Transition check only: when a concrete consumer use case exists, either a browser-WASM route landing in an assembly (alkcall ADR-009 sketch) or a hub deployment needing SSH-in-relay, mark this task `completed` and move OQ-01 from `deferred(scope)` to `open` in `open-questions.md` (directly to `resolved` if no decision remains, which would be an ADR-001 amendment since the pin is one-way).
## Verification ## Verification
@@ -29,7 +29,7 @@ Transition check only: when a concrete consumer use case exists — a browser-WA
## Out of scope ## Out of scope
Any actual relay/browser-leg design — that work would be new ADR/spec territory once the use case exists. Any actual relay/browser-leg design; that work would be new ADR/spec territory once the use case exists.
## Summary ## Summary
@@ -1,6 +1,6 @@
--- ---
id: architecture/oq-04-openssh-cert-auth id: architecture/oq-04-openssh-cert-auth
name: "OQ-04 tracker — OpenSSH certificate auth use case" name: "OQ-04 tracker: OpenSSH certificate auth use case"
status: pending status: pending
depends_on: [] depends_on: []
scope: single scope: single
@@ -12,7 +12,7 @@ tags: [deferred-oq, external-trigger]
## Description ## Description
External-trigger tracker for [OQ-04](docs/architecture/open-questions.md) — whether an OpenSSH-CA-trust use case ever arrives for `auth_openssh_certificate` (deferred(scope)). Not actionable work; decline is the standing disposition (ADR-004 territory). External-trigger tracker for [OQ-04](docs/architecture/open-questions.md): does an OpenSSH-CA-trust use case ever arrive for `auth_openssh_certificate`? (deferred(scope)). Not actionable work; decline is the standing disposition (ADR-004 territory).
## Work ## Work
@@ -1,6 +1,6 @@
--- ---
id: architecture/oq-09-env-request-allowlist id: architecture/oq-09-env-request-allowlist
name: "OQ-09 tracker — env_request allowlist use case" name: "OQ-09 tracker: env_request allowlist use case"
status: pending status: pending
depends_on: [] depends_on: []
scope: single scope: single
@@ -12,7 +12,7 @@ tags: [deferred-oq, external-trigger]
## Description ## Description
External-trigger tracker for [OQ-09](docs/architecture/open-questions.md) — whether a consuming assembly ever wants a wire-supplied env allowlist on session channels (deferred(scope)). Decline-by-default is standing posture. External-trigger tracker for [OQ-09](docs/architecture/open-questions.md): whether a consuming assembly ever wants a wire-supplied env allowlist on session channels (deferred(scope)). Decline-by-default is standing posture.
## Work ## Work
+2 -2
View File
@@ -1,6 +1,6 @@
--- ---
id: architecture/oq-13-alkfs-sftp id: architecture/oq-13-alkfs-sftp
name: "OQ-13 tracker — alkfs landed (sftp implementation unblocks)" name: "OQ-13 tracker: alkfs landed, sftp implementation unblocks"
status: pending status: pending
depends_on: [] depends_on: []
scope: single scope: single
@@ -12,7 +12,7 @@ tags: [deferred-oq, external-trigger]
## Description ## Description
External-trigger tracker for [OQ-13](docs/architecture/open-questions.md) — the `sftp` feature's implementation waits for alkfs (family ADR-002, paused for alkforge). The `sftp` feature ships declining (tested decline contract on both directions) until this unblocks. External-trigger tracker for [OQ-13](docs/architecture/open-questions.md): the `sftp` feature's implementation waits for alkfs (family ADR-002, paused for alkforge). The `sftp` feature ships declining (tested decline contract on both directions) until this unblocks.
## Work ## Work
+34 -33
View File
@@ -1,6 +1,6 @@
--- ---
id: review-wave-1 id: review-wave-1
name: Wave 1 gate — the foundations review name: "Wave 1 gate: the foundations review"
status: completed status: completed
depends_on: [w1-manifest, w1-error-model, w1-key-sources, w1-host-key-policy, w1-fingerprint, w1-config, w1-test-doubles] depends_on: [w1-manifest, w1-error-model, w1-key-sources, w1-host-key-policy, w1-fingerprint, w1-config, w1-test-doubles]
scope: narrow scope: narrow
@@ -11,7 +11,7 @@ level: review
## Description ## Description
The wave-1 review gate — the cheap-place gate. The config types, the The wave-1 review gate, the cheap-place gate of the plan. The config types, the
error taxonomy, and the key-source seam are one-way doors in practice: error taxonomy, and the key-source seam are one-way doors in practice:
every later task implements against them, and the published crate every later task implements against them, and the published crate
freezes them. A shape error found here costs a task revision; found freezes them. A shape error found here costs a task revision; found
@@ -21,24 +21,24 @@ across both directions.
The gate reads the landed surface against the architecture line by The gate reads the landed surface against the architecture line by
line: line:
- ADR-002 — the pin as declared (russh 0.65, `aws-lc-rs` only, no - ADR-002, the pin as declared (russh 0.65, `aws-lc-rs` only, no
`flate2`/`rsa` in the tree, edition 2024, MSRV 1.89); the recorded `flate2`/`rsa` in the tree, edition 2024, MSRV 1.89); the recorded
`ssh-key` RC exception verified in `cargo tree`. `ssh-key` RC exception verified in `cargo tree`.
- ADR-003 — the feature graph shape (flat, independent, `default = - ADR-003, the feature graph shape (flat, independent, `default =
[]`); the recorded interpretation (per-feature sibling deps land []`); the recorded interpretation (per-feature sibling deps land
with their first consuming wave) confirmed or corrected. with their first consuming wave) confirmed or corrected.
- ADR-004 — the fingerprint format invariant (parity vectors green); - ADR-004, the fingerprint format invariant (parity vectors green);
the `max_auth_attempts` default posture in `Limits`. the `max_auth_attempts` default posture in `Limits`.
- ADR-005 — the custody chain (decrypt-at-use, `Zeroizing` wrap, - ADR-005, the custody chain (decrypt-at-use, `Zeroizing` wrap,
distinct failure variants, `EncryptedData` opaque, host-key load distinct failure variants, `EncryptedData` opaque, host-key load
path, plaintext never persists). path, plaintext never persists).
- OQ-05 — the policy seam (fail-closed default, no accept-all - OQ-05, the policy seam (fail-closed default, no accept-all
built-in, policy-blind crate). built-in, policy-blind crate).
- OQ-08/09 — the serve-config `Option`-field semantics (unset → - OQ-08/09, the serve-config `Option`-field semantics (unset →
decline, no crate default, no construction-time validation drift). decline, no crate default, no construction-time validation drift).
- The error model — no catch-alls; auth declines are dispositions, not - The error model: no catch-alls; auth declines are dispositions, not
errors. errors.
- Docs honesty — module docs state what is deliberately not here yet - Docs honesty, or that module docs state what is deliberately not here yet
(the stack, the mappings, the client direction). (the stack, the mappings, the client direction).
Verdict: green (wave 2 decomposes, absorbing corrections) or findings Verdict: green (wave 2 decomposes, absorbing corrections) or findings
@@ -66,23 +66,23 @@ booked (remediation before wave 2 decomposes).
### Gate reading (line by line, 2026-10-28) ### Gate reading (line by line, 2026-10-28)
- **ADR-002** ✓ — `Cargo.toml` carries `russh = { version = "0.65", - **ADR-002** ✓, because `Cargo.toml` carries `russh = { version = "0.65",
default-features = false, features = ["aws-lc-rs"] }`; edition 2024, default-features = false, features = ["aws-lc-rs"] }`; edition 2024,
`rust-version = "1.89"`; `cargo tree -e normal` confirms **no `rust-version = "1.89"`; `cargo tree -e normal` confirms **no
`rsa`, no `flate2`, no `ring`**, and the recorded `rsa`, no `flate2`, no `ring`**, and the recorded
`ssh-key =0.7.0-rc.11` RC exception riding the russh keys path is `ssh-key =0.7.0-rc.11` RC exception riding the russh keys path is
present exactly as ADR-002 records it. present exactly as ADR-002 records it.
- **ADR-003** ✓ — `session`/`git`/`tunnel`/`sftp` declared, flat, - **ADR-003** ✓, since `session`/`git`/`tunnel`/`sftp` declared, flat,
`default = []`, nothing implied; the per-feature sibling deps `default = []`, nothing implied; the per-feature sibling deps
(`alktty`/`alktunnels`/`alkgit`) are **not** in the manifest — the (`alktty`/`alktunnels`/`alkgit`) are **not** in the manifest; the
recorded interpretation (they land with their first consuming wave) recorded interpretation (they land with their first consuming wave)
confirmed as recorded. confirmed as recorded.
- **ADR-004** ✓ — `src/fingerprint.rs` is the single fingerprint path; - **ADR-004** ✓, since `src/fingerprint.rs` is the single fingerprint path;
both parity suites (`tests/fingerprint_parity.rs`, both parity suites (`tests/fingerprint_parity.rs`,
`tests/doubles.rs` fixture rows) are green against the `tests/doubles.rs` fixture rows) are green against the
`ssh-keygen -lf`-recorded manifest; `Limits::max_auth_attempts` `ssh-keygen -lf`-recorded manifest; `Limits::max_auth_attempts`
defaults to 10 with the decision-6 rationale in its doc. defaults to 10 with the decision-6 rationale in its doc.
- **ADR-005** ✓ — the custody chain is in code: one - **ADR-005** ✓, as the custody chain is in code: one
`vault.decrypt` → `Zeroizing<String>` wrap at the adapter seam → `vault.decrypt` → `Zeroizing<String>` wrap at the adapter seam →
one-time `decode_secret_key`; the vault's taxonomy reshaped to the one-time `decode_secret_key`; the vault's taxonomy reshaped to the
five distinct variants (no re-export, no collapse); `EncryptedData` five distinct variants (no re-export, no collapse); `EncryptedData`
@@ -92,32 +92,33 @@ booked (remediation before wave 2 decomposes).
no plaintext persistence anywhere. The known-gap (vault's plain no plaintext persistence anywhere. The known-gap (vault's plain
`String` return) is documented as the ADR-005 decision-3 contract, `String` return) is documented as the ADR-005 decision-3 contract,
not silently absorbed. not silently absorbed.
- **OQ-05** ✓ — `HostKeyPolicy` seam with `RejectAll` fail-closed - **OQ-05** ✓: the `HostKeyPolicy` seam offers a `RejectAll` fail-closed
default; **no accept-all built-in** (the permissive posture exists default; **no accept-all built-in** (the permissive posture exists
only as the double's explicit `with_default(true)` choice in tests — only as the double's explicit `with_default(true)` choice in tests,
correct posture); the crate is policy-blind. which is the correct posture); the crate is policy-blind.
- **OQ-08/09** ✓ — `shell_argv`/`exec_wrapper_argv` are `Option` with - **OQ-08/09** ✓: `shell_argv` and
`exec_wrapper_argv` are both `Option` with
unset→decline semantics in the type docs; no construction-time unset→decline semantics in the type docs; no construction-time
validation, no config-error type (the recorded interpretation validation, no config-error type (the recorded interpretation
confirmed — none was needed); `session_cwd`/`session_env` are confirmed, and none was needed); `session_cwd`/`session_env` are
assembly-supplied, never wire-derived; `channel_client` absent → assembly-supplied, never wire-derived; `channel_client` absent →
tunnel rows decline even under the feature. tunnel rows decline even under the feature.
- **Error model** ✓ — five closed variants, no catch-all; the - **Error model** ✓: five closed variants, no catch-all; the
dispositions-not-errors contract (auth declines, wire declines, dispositions-not-errors contract (auth declines, wire declines,
config validation) is written into `error.rs`'s module docs. config validation) is written into `error.rs`'s module docs.
- **Docs honesty** ✓ — every module's docs state what is deliberately - **Docs honesty** ✓: every module's docs state what is deliberately
not here yet (russh `Config` construction and the dispatch are not here yet (russh `Config` construction and the dispatch are
wave-2; dial wiring is wave-4; the grammar is wave-3). wave-2; dial wiring is wave-4; the grammar is wave-3).
### Interpretations (flagged for this gate) — all confirmed ### Interpretations (flagged for this gate), all confirmed
1. Sibling-dep timing — confirmed (see ADR-003 above). 1. Sibling-dep timing: confirmed (see ADR-003 above).
2. Vault-double shape — confirmed: `TestVault` wraps alkvault 0.1's 2. Vault-double shape, confirmed: `TestVault` wraps alkvault 0.1's
own in-memory construction (`VaultServiceHandle::new()` + own in-memory construction (`VaultServiceHandle::new()` +
`unlock_new`) rather than re-implementing behind the source trait, `unlock_new`) rather than re-implementing behind the source trait,
so the real error taxonomy and GCM behavior ride every row (the so the real error taxonomy and GCM behavior ride every row (the
decision is recorded in `tests/common/mod.rs`'s module docs). decision is recorded in `tests/common/mod.rs`'s module docs).
3. Policy verdict type — confirmed: accept/reject `bool`, pinned to 3. Policy verdict type, confirmed: accept/reject `bool`, pinned to
the russh 0.65 `check_server_key → Result<bool, E>` hook shape, the russh 0.65 `check_server_key → Result<bool, E>` hook shape,
with the `Err` channel held for wire/IO failures (recorded in with the `Err` channel held for wire/IO failures (recorded in
`host_key.rs`'s module docs). `host_key.rs`'s module docs).
@@ -127,21 +128,21 @@ booked (remediation before wave 2 decomposes).
Small doc/polish findings; each is a one-to-few-line fix: Small doc/polish findings; each is a one-to-few-line fix:
- `cargo doc --no-deps` emitted 2 rustdoc warnings (ambiguous - `cargo doc --no-deps` emitted 2 rustdoc warnings (ambiguous
`[`async_trait`]` intra-doc links — crate and attribute-macro share `[`async_trait`]` intra-doc links, since the crate and the attribute-macro share
the name) in `key_source.rs` and `host_key.rs` → resolved as plain the name) in `key_source.rs` and `host_key.rs` → resolved as plain
code spans. The crates' verification set does not run rustdoc, so code spans. The crates' verification set does not run rustdoc, so
these had gone unnoticed — worth remembering at wave 5's these had gone unnoticed, which is worth remembering at wave 5's
"public-API rustdoc warning-free" gate. "public-API rustdoc warning-free" gate.
- `key_source.rs` `HostKeySource` docs: stray `)` typo ("on host - `key_source.rs` `HostKeySource` docs: stray `)` typo ("on host
*pin*) trust"). *pin*) trust").
- `lib.rs` crate docs: stale line ("the dependency surface and - `lib.rs` crate docs: stale line ("the dependency surface and
feature graph land with implementation start") — now landed; feature graph land with implementation start") is now landed;
rewritten to state the landed/future split honestly. rewritten to state the landed/future split honestly.
- Two redundant test lines removed (a discarded pre-probe - Two redundant test lines removed (a discarded pre-probe
`try_sign` in `key_source.rs`'s usability helper; a redundant `try_sign` in `key_source.rs`'s usability helper; a redundant
`None` re-assignment in `config.rs`'s independent-flip row). `None` re-assignment in `config.rs`'s independent-flip row).
Nothing found rose to the level of a booked finding — no shape error Nothing found rose to the level of a booked finding, and no shape error
in the config types, the error taxonomy, or the key-source seam. in the config types, the error taxonomy, or the key-source seam.
### Verification (all green) ### Verification (all green)
@@ -159,9 +160,9 @@ in the config types, the error taxonomy, or the key-source seam.
ADR-002/003/004/005 and the OQ-05/08/09 resolutions, line by line; ADR-002/003/004/005 and the OQ-05/08/09 resolutions, line by line;
all three recorded interpretations (sibling-dep timing, vault-double all three recorded interpretations (sibling-dep timing, vault-double
shape, policy verdict type) were confirmed as recorded. Only minor shape, policy verdict type) were confirmed as recorded. Only minor
doc/polish findings surfaced — fixed inline (rustdoc link doc/polish findings surfaced, and they were fixed inline (rustdoc link
disambiguation, a doc typo, a stale crate-doc line, two redundant disambiguation, a doc typo, a stale crate-doc line, two redundant
test lines), recorded above. No shape errors in the one-way-door test lines), recorded above. No shape errors in the one-way-door
surfaces. Wave 2 decomposition is unblocked; it should absorb the surfaces. Wave 2 decomposition is unblocked; it should absorb the
rustdoc-hygiene lesson (run `cargo doc --no-deps` on gates) — no fix rustdoc-hygiene lesson (run `cargo doc --no-deps` on gates), though no fix
tasks needed. tasks are needed.
+39 -39
View File
@@ -1,6 +1,6 @@
--- ---
id: review-wave-2 id: review-wave-2
name: Wave 2 gate — the server-stack review name: "Wave 2 gate: the server-stack review"
status: completed status: completed
depends_on: [w2-server-mount, w2-auth-bridge, w2-loopback-harness, w2-dispatch-decline-core, w2-spawn-return, w2-bounded-resources] depends_on: [w2-server-mount, w2-auth-bridge, w2-loopback-harness, w2-dispatch-decline-core, w2-spawn-return, w2-bounded-resources]
scope: narrow scope: narrow
@@ -13,39 +13,39 @@ level: review
The wave-2 review gate. The server stack is the substrate every 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 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 it; wave 4 shares its foundations), so a shape error in the mount
signature, the auth-bridge state, or the dispatch routing costs churn signature, the auth-bridge state, or the dispatch routing costs churn
across all of them. The gate reads the landed stack against the across all of them. The gate reads the landed stack against the
architecture line by line: architecture line by line:
- **ADR-001** — the mount contract: `SshAdapter::handle` takes a - **ADR-001**, the mount contract: `SshAdapter::handle` takes a
generic stream, mounts the full russh stack per stream, edge generic stream, mounts the full russh stack per stream, edge
termination only; no ALPN/relay code exists; the per-stream limits termination only; no ALPN/relay code exists; the per-stream limits
posture holds. posture holds.
- **ADR-002** — the Config construction (honest advertisement - **ADR-002**, the Config construction (honest advertisement
`{publickey}` exactly; the knob mapping; no `des`/`dsa` posture `{publickey}` exactly; the knob mapping; no `des`/`dsa` posture
drift; the pin untouched). drift; the pin untouched).
- **ADR-003** — the feature gates as the dispatch's first `cfg` - **ADR-003**, the feature gates as the dispatch's first `cfg`
consumers: flat, independent, `default = []` declines everything; consumers: flat, independent, `default = []` declines everything;
x11/auth-agent never feature-gated; honest advertisement per build. x11/auth-agent never feature-gated; honest advertisement per build.
- **ADR-004** — the dispositions in code (explicit declines for every - **ADR-004**, the dispositions in code (explicit declines for every
non-publickey method; offered-hook accept-and-delegate; ed25519-only non-publickey method; offered-hook accept-and-delegate; ed25519-only
explicit decline; fingerprint-only resolution; the identity rides explicit decline; fingerprint-only resolution; the identity rides
the session). the session).
- **ADR-006** — parse-only dispatch (no detect residue); decline-first- - **ADR-006**, parse-only dispatch (no detect residue); decline-first-
alloc-never structurally; **the spawn-and-return constraint pinned alloc-never structurally; **the spawn-and-return constraint pinned
by test, not convention**. by test, not convention**.
- **`mapping.md`** — the skeleton decline-matrix rows green per build - **`mapping.md`**, the skeleton decline-matrix rows green per build
through the harness; the wire decline reasons vanilla-observable. through the harness; the wire decline reasons vanilla-observable.
- **Bounded resources** — the cap enforced and recovering; the knobs - **Bounded resources**, the cap enforced and recovering; the knobs
behaviorally verified or recorded; no unbounded shape anywhere. behaviorally verified or recorded; no unbounded shape anywhere.
- **The harness** — the contract-test vehicle convention established; - **The harness**, with the contract-test vehicle convention established;
the wave-1 doubles exercised live. the wave-1 doubles exercised live.
- **Docs honesty** — module docs state what is deliberately not here - **Docs honesty**: module docs state what is deliberately not here
yet (the family seams, the client direction); the skeleton-stub yet (the family seams, the client direction); the skeleton-stub
behavior (declining routes awaiting wave 3) documented as such. behavior (declining routes awaiting wave 3) documented as such.
- **The wave-1 gate's lesson absorbed** — `cargo doc --no-deps` runs - **The wave-1 gate's lesson absorbed**, with `cargo doc --no-deps`
clean on this gate too. running clean on this gate too.
Verdict: green (wave 3 decomposes, absorbing corrections) or findings Verdict: green (wave 3 decomposes, absorbing corrections) or findings
booked (remediation before wave 3 decomposes). booked (remediation before wave 3 decomposes).
@@ -71,16 +71,16 @@ booked (remediation before wave 3 decomposes).
### Gate reading (line by line, 2026-10-28; commits b90e89b…196289f) ### Gate reading (line by line, 2026-10-28; commits b90e89b…196289f)
- **ADR-001** ✓ — `SshAdapter::handle` is the mount: generic - **ADR-001** ✓, as `SshAdapter::handle` is the mount: generic
`AsyncRead + AsyncWrite` stream in, the full russh stack per stream `AsyncRead + AsyncWrite` stream in, the full russh stack per stream
via `run_stream`, returned **run to completion** via `run_stream`, returned **run to completion**
(`Result<(), AdapterError>` — the decision recorded in the task's (`Result<(), AdapterError>`, a decision recorded in the task's
notes; no russh type rides the signature). No ALPN code, no relay notes; no russh type rides the signature). No ALPN code, no relay
leg, no `alk/ssh` string anywhere in `src/`. The per-stream limits leg, no `alk/ssh` string anywhere in `src/`. The per-stream limits
posture holds: each mounted stream builds its own `SessionHandler` posture holds: each mounted stream builds its own `SessionHandler`
over the serve-config, so the cap/timeout/window budget is over the serve-config, so the cap/timeout/window budget is
per-stream exactly as ADR-001's neutral consequence states. per-stream exactly as ADR-001's neutral consequence states.
- **ADR-002** ✓ — the `Config` construction maps `Limits` one-for-one - **ADR-002** ✓, as the `Config` construction maps `Limits` one-for-one
(`window_size` / `maximum_packet_size` / `channel_buffer_size` / (`window_size` / `maximum_packet_size` / `channel_buffer_size` /
`max_auth_attempts` verbatim; `inactivity_timeout` **always `max_auth_attempts` verbatim; `inactivity_timeout` **always
`Some`** with a test pinning the default postures and a second `Some`** with a test pinning the default postures and a second
@@ -91,7 +91,7 @@ booked (remediation before wave 3 decomposes).
`aws-lc-rs`, no `flate2`/`rsa`/`des`/`dsa`); the recorded `aws-lc-rs`, no `flate2`/`rsa`/`des`/`dsa`); the recorded
deliberations (`server_id` stays russh-default; keepalive stays deliberations (`server_id` stays russh-default; keepalive stays
client-direction) are in the task's notes. client-direction) are in the task's notes.
- **ADR-003** ✓ — the wave-2 dispatch core is the feature graph's - **ADR-003** ✓, since the wave-2 dispatch core is the feature graph's
first `cfg` consumer: flat, independent gates read via `cfg!` first `cfg` consumer: flat, independent gates read via `cfg!`
predicates (`session ∨ git` for session channels; predicates (`session ∨ git` for session channels;
`tunnel` for the three tunnel opens; `session` for pty/shell/pipe `tunnel` for the three tunnel opens; `session` for pty/shell/pipe
@@ -100,36 +100,36 @@ booked (remediation before wave 3 decomposes).
forwarding decline unconditionally, never feature-gated; the `sftp` forwarding decline unconditionally, never feature-gated; the `sftp`
feature gates nothing and the subsystem row declines in every feature gates nothing and the subsystem row declines in every
build (OQ-13 placeholder). build (OQ-13 placeholder).
- **ADR-004** ✓ — all dispositions in code as explicit hook - **ADR-004** ✓, with all dispositions in code as explicit hook
implementations: `auth_publickey` (ed25519-only gate checked on implementations: `auth_publickey` (ed25519-only gate checked on
**both** the signature algorithm and the key's algorithm before any **both** the signature algorithm and the key's algorithm before any
resolution → fingerprint → `resolve_from_fingerprint` → resolution → fingerprint → `resolve_from_fingerprint` →
accept-with-identity / reject fail-closed), the offered hook accept-with-identity / reject fail-closed), the offered hook
accept-and-delegates, and the four non-publickey methods each have accept-and-delegates, and the four non-publickey methods each have
an explicit reject body (never inherited). The bridge core an explicit reject body (never inherited). The bridge core
(`resolve_bridge`) takes no username parameter — OQ-03 holds (`resolve_bridge`) takes no username parameter, so OQ-03 holds
structurally; wire rows prove username-independence. Auth declines structurally; wire rows prove username-independence. Auth declines
are `Auth::Reject` dispositions end to end. The identity lands in are `Auth::Reject` dispositions end to end. The identity lands in
per-connection handler state per-connection handler state
(`SessionHandler::resolved_identity`); its *session-level landing* (`SessionHandler::resolved_identity`); its *session-level landing*
is wave-3 assertion territory (flagged in the task's notes — code is wave-3 assertion territory (flagged in the task's notes, as code
verifiable only through `auth_succeeded` consumption), confirmed as is verifiable only through `auth_succeeded` consumption), confirmed as
correctly deferred. correctly deferred.
- **ADR-006** ✓ — parse-only dispatch with no detect residue (the - **ADR-006** ✓, showing parse-only dispatch with no detect residue (the
SOCKS-detect is gone; a request-less session channel serves SOCKS-detect is gone; a request-less session channel serves
nothing by construction — no serving path exists outside the nothing by construction, since no serving path exists outside the
request hooks); decline-first-alloc-never verified structurally: request hooks); decline-first-alloc-never verified structurally:
every open-level decline answers before any accept; the only every open-level decline answers before any accept; the only
pre-decline resource on any request path is none (exec parses via a pre-decline resource on any request path is none (exec parses via a
pure fn first), and the spawned serving paths' only allocation is pure fn first), and the spawned serving paths' only allocation is
the post-decision handle clone; spawn-and-return is **pinned by the post-decision handle clone; spawn-and-return is **pinned by
test, not convention** — the `serve.rs` unit row (spawn returns test, not convention**; the `serve.rs` unit row (spawn returns
while the path is pending) plus the two wire rows while the path is pending) plus the two wire rows
(`tests/spawn_return.rs`: handler-returns-fast under follow-up (`tests/spawn_return.rs`: handler-returns-fast under follow-up
traffic with the spawn still gated; exit-status strictly before traffic with the spawn still gated; exit-status strictly before
close, timestamps proving the report outlived the handler's close, timestamps proving the report outlived the handler's
return). return).
- **mapping.md** ✓ — the skeleton decline-matrix rows are green per - **mapping.md** ✓, with the skeleton decline-matrix rows green per
build through the harness build through the harness
(`tests/dispatch_core.rs`: shared rows in every build; per-build (`tests/dispatch_core.rs`: shared rows in every build; per-build
modules cfg-pinned to exactly one feature combination so no modules cfg-pinned to exactly one feature combination so no
@@ -139,7 +139,7 @@ booked (remediation before wave 3 decomposes).
(`AdministrativelyProhibited` / the tunnel stub's `ConnectFailed`), (`AdministrativelyProhibited` / the tunnel stub's `ConnectFailed`),
request-level capability declines answer `channel_failure` with request-level capability declines answer `channel_failure` with
the channel provably still usable. the channel provably still usable.
- **Bounded resources** ✓ — the in-crate cap is enforced at the - **Bounded resources** ✓, with the in-crate cap enforced at the
handler surface, recovering on close (wire row: cap reached → handler surface, recovering on close (wire row: cap reached →
decline; close → open succeeds again, deterministic on the FIFO decline; close → open succeeds again, deterministic on the FIFO
wire ordering; zero-cap row refuses everything without poisoning); wire ordering; zero-cap row refuses everything without poisoning);
@@ -152,49 +152,49 @@ booked (remediation before wave 3 decomposes).
(wave-3 grammar's consumption, per the plan bullet). No unbounded (wave-3 grammar's consumption, per the plan bullet). No unbounded
shape anywhere: accepted-but-unserved session channels hold bounded shape anywhere: accepted-but-unserved session channels hold bounded
buffers only (window/buffer knobs). buffers only (window/buffer knobs).
- **The harness** ✓ — the contract-test vehicle convention - **The harness** ✓, with the contract-test vehicle convention
established exactly as the task pinned it: tokio-duplex transport established exactly as the task pinned it: tokio-duplex transport
+ `connect_stream` (decision recorded in the module docs, over TCP + `connect_stream` (decision recorded in the module docs, over TCP
loopback), sibling-of-the-doubles placement under `tests/common/`, loopback), sibling-of-the-doubles placement under `tests/common/`,
the `drive` vocabulary the wave-3 mappings inherit, and the wave-1 the `drive` vocabulary the wave-3 mappings inherit, and the wave-1
doubles exercised live through it (`MemIdentityProvider`, doubles exercised live through it (`MemIdentityProvider`,
`MemHostKeySource`, `MemHostKeyPolicy` both verdicts, fixture `MemHostKeySource`, `MemHostKeyPolicy` both verdicts, fixture
plumbing) — the acceptance row plumbing), as the acceptance row
(`in_process_pair_connects_and_authenticates_end_to_end`) plus the (`in_process_pair_connects_and_authenticates_end_to_end`) plus the
auth-bridge and dispatch rows all ride it. auth-bridge and dispatch rows all ride it.
- **Docs honesty** ✓ — the handler module docs state precisely what - **Docs honesty** ✓, as the handler module docs state precisely what
is deliberately not here (the family seams, the client direction, is deliberately not here (the family seams, the client direction,
the store-and-take handoff, the wave-3 tunnel accept-flow), each the store-and-take handoff, the wave-3 tunnel accept-flow), each
seam stub documents its wave-3 replacement point, and the seam stub documents its wave-3 replacement point, and the
inherited-posture note (the bare `auth-agent` / inherited-posture note (the bare `auth-agent` /
`forwarded-streamlocal` channel types decline via russh's own `forwarded-streamlocal` channel types decline via russh's own
inline rejection at this pin — flagged as an upstream watch item, a inline rejection at this pin, flagged as an upstream watch item since a
pin bump that changed it would surface as accepted-then-dropped) is pin bump that changed it would surface as accepted-then-dropped) is
recorded rather than silently relied on. recorded rather than silently relied on.
- **The wave-1 gate's lesson absorbed** ✓ — `cargo doc --no-deps` - **The wave-1 gate's lesson absorbed** ✓, as `cargo doc --no-deps`
ran clean on this gate (0 warnings), and `taskgraph validate` passes. ran clean on this gate (0 warnings), and `taskgraph validate` passes.
### Interpretations (flagged for this gate) — all confirmed ### Interpretations (flagged for this gate), all confirmed
1. **Git-parseability probe is skeleton-level** — confirmed: 1. **Git-parseability probe is skeleton-level**, confirmed:
`classify_exec` carries service-prefix + path-shape only, its `classify_exec` carries service-prefix + path-shape only, its
module docs name the wave-3 replacement explicitly, and the probe module docs name the wave-3 replacement explicitly, and the probe
rows pin exactly the POC-3 stage-A facts the skeleton needs rows pin exactly the POC-3 stage-A facts the skeleton needs
(unknown service → pipe row; total over arbitrary input). (unknown service → pipe row; total over arbitrary input).
2. **Routing stubs decline at the family-seam boundary, documented as 2. **Routing stubs decline at the family-seam boundary, documented as
skeleton** — confirmed: the stub bodies are the named wave-3 skeleton**; confirmed: the stub bodies are the named wave-3
replacement points (`git_row_stub` … `tunnel_row_stub`), the replacement points (`git_row_stub` … `tunnel_row_stub`), the
serving-path decline shape (failure then close) is wire-pinned, serving-path decline shape (failure then close) is wire-pinned,
and the tunnel stub's `ConnectFailed` reason matches ADR-006 and the tunnel stub's `ConnectFailed` reason matches ADR-006
decision 1's fail shape. decision 1's fail shape.
3. **Adapter error surface shaped in `w2-server-mount`** — confirmed: 3. **Adapter error surface shaped in `w2-server-mount`**, confirmed:
`AdapterError` is the closed two-class taxonomy `AdapterError` is the closed two-class taxonomy
(`Construction(#[from] KeySourceError)` + `Wire(#[from] (`Construction(#[from] KeySourceError)` + `Wire(#[from]
russh::Error)`) with the locked→no-server rule at construction and russh::Error)`) with the locked→no-server rule at construction and
no catch-all; the wire decline reasons landed in the dispatch, not no catch-all; the wire decline reasons landed in the dispatch, not
in the error taxonomy (the wave-1 reservation spent correctly). in the error taxonomy (the wave-1 reservation spent correctly).
4. **Harness transport and placement pinned in 4. **Harness transport and placement pinned in
`w2-loopback-harness`** — confirmed, recorded in its module docs. `w2-loopback-harness`**, confirmed and recorded in its module docs.
### Minor issues found and fixed inline (this session) ### Minor issues found and fixed inline (this session)
@@ -212,7 +212,7 @@ Doc-hygiene only, in the task files (no `src/` change warranted):
- **No observability instrumentation on the decline paths.** The - **No observability instrumentation on the decline paths.** The
request-level decline class's "reason" is currently only the request-level decline class's "reason" is currently only the
routing decision documented per build — nothing server-side logs routing decision documented per build, so nothing server-side logs
which reason a given wire decline carried (`tracing` is in the which reason a given wire decline carried (`tracing` is in the
manifest per the ADR-002 toolkit but unused in `src/` so far). Small manifest per the ADR-002 toolkit but unused in `src/` so far). Small
follow-up task for the next wave's decomposition (alongside the follow-up task for the next wave's decomposition (alongside the
@@ -228,7 +228,7 @@ Doc-hygiene only, in the task files (no `src/` change warranted):
flow. flow.
No shape errors found in the mount signature, the auth-bridge state, No shape errors found in the mount signature, the auth-bridge state,
or the dispatch routing — the three churn-critical surfaces this gate or the dispatch routing, the three churn-critical surfaces this gate
exists to protect. exists to protect.
### Verification (all green) ### Verification (all green)
@@ -253,7 +253,7 @@ everything, x11/auth-agent never gated), ADR-004's dispositions as
explicit hook code (ed25519-only fingerprint bridge, four explicit explicit hook code (ed25519-only fingerprint bridge, four explicit
declines, username structurally excluded), ADR-006's constraints declines, username structurally excluded), ADR-006's constraints
parse-only dispatch, decline-first-alloc-never structural, and parse-only dispatch, decline-first-alloc-never structural, and
spawn-and-return pinned by test over the real wire — plus the bounded spawn-and-return pinned by test over the real wire, plus the bounded
in-crate channel cap enforced and recovering at the handler surface, in-crate channel cap enforced and recovering at the handler surface,
the skeleton decline-matrix rows green per feature build through the the skeleton decline-matrix rows green per feature build through the
loopback harness, and docs that state what is deliberately deferred. loopback harness, and docs that state what is deliberately deferred.
+51 -52
View File
@@ -1,6 +1,6 @@
--- ---
id: review-wave-3 id: review-wave-3
name: Wave 3 gate — the server-mappings review name: "Wave 3 gate: the server-mappings review"
status: completed status: completed
depends_on: [w3-seam-config, w3-decline-tracing, w3-git-mapping, w3-tty-session, w3-tunnel-direct, w3-tunnel-listen] depends_on: [w3-seam-config, w3-decline-tracing, w3-git-mapping, w3-tty-session, w3-tunnel-direct, w3-tunnel-listen]
scope: narrow scope: narrow
@@ -12,53 +12,53 @@ level: review
## Description ## Description
The wave-3 review gate. The server mappings are the crate's spine The wave-3 review gate. The server mappings are the crate's spine
landed in wire-capable form — the wave-4 client direction reuses the landed in wire-capable form, and the wave-4 client direction reuses the
same grammar/rows pointed upstream, wave 5 builds the full matrix on same grammar/rows pointed upstream, wave 5 builds the full matrix on
these rows, and the published crate freezes this surface. The gate these rows, and the published crate freezes this surface. The gate
reads the landed mappings against the architecture line by line: reads the landed mappings against the architecture line by line:
- **mapping.md line-by-line** — each of the spine's server rows - **mapping.md line-by-line**, covering each of the spine's server rows
(session/pty/shell/pipe; git exec; direct-tcpip; (session/pty/shell/pipe; git exec; direct-tcpip;
direct-streamlocal; forwarded-tcpip; the global listen rows; the direct-streamlocal; forwarded-tcpip; the global listen rows; the
request-level table) read against the landed code; the §exit request-level table) read against the landed code; the §exit
derivation contract rules 1–6 verified in code + rows; the decline derivation contract rules 1–6 verified in code + rows; the decline
matrix green **per feature build** through the harness (including matrix green **per feature build** through the harness (including
the unset-field rows — feature on, handle unset). the unset-field rows, with the feature on and the handle unset).
- **ADR-006** — route-1 bridge (accept only after the fabric open - **ADR-006**, route-1 bridge (accept only after the fabric open
succeeds; `ConnectFailed` on failure), exec-only git (no subsystem succeeds; `ConnectFailed` on failure), exec-only git (no subsystem
dispatch anywhere in the grammar), parse-only dispatch still dispatch anywhere in the grammar), parse-only dispatch still
structural (no request-less serving), spawn-and-return over the structural (no request-less serving), spawn-and-return over the
**real** serving paths (the wave-2 constraint re-pinned over **real** serving paths (the wave-2 constraint re-pinned over
mappings, not stubs). mappings, not stubs).
- **ADR-003** — the per-build matrices; honest advertisement - **ADR-003**, the per-build matrices; honest advertisement
unchanged (an unfeatured type declines; the sftp feature still unchanged (an unfeatured type declines; the sftp feature still
gates nothing, its rows still decline in every build — OQ-13); gates nothing, its rows still decline in every build, per OQ-13);
x11/auth-agent never-feature rows untouched. x11/auth-agent never-feature rows untouched.
- **Auth.md / identity** — the identity ride observable at every seam - **Auth.md / identity**, the identity ride observable at every seam
(alktty/alkgit params; the channels connection for tunnels), (alktty/alkgit params; the channels connection for tunnels),
`set_identity` once-only mechanics recorded; the parked wave-2 `set_identity` once-only mechanics recorded; the parked wave-2
`auth_succeeded` item verified discharged; no username anywhere. `auth_succeeded` item verified discharged; no username anywhere.
- **OQ resolutions in code** — OQ-08 (unset-declines on shell/wrapper - **OQ resolutions in code**, namely OQ-08 (unset-declines on shell/wrapper
rows), OQ-09 (env declined), OQ-10 (derivation rules + the rows), OQ-09 (env declined), OQ-10 (derivation rules + the
PTY-path fidelity limit documented), OQ-11 (runtime-Unix gates PTY-path fidelity limit documented), OQ-11 (runtime-Unix gates
compile-everywhere + clean off-Unix declines), OQ-13 (sftp rows compile-everywhere + clean off-Unix declines), OQ-13 (sftp rows
unchanged). unchanged).
- **Bounded resources** — no new unbounded shape: the cap across the - **Bounded resources**, with no new unbounded shape: the cap across the
fabric window (release-on-failure pinned), the listener bookkeeping fabric window (release-on-failure pinned), the listener bookkeeping
bounded, the control-row surfaces bounded, the bridge adapters bounded, the control-row surfaces bounded, the bridge adapters
bounded. bounded.
- **Tracing** — the uniform event vocabulary across all landed - **Tracing**, the uniform event vocabulary across all landed
serve/decline paths; the safety posture held (no secrets, no serve/decline paths; the safety posture held (no secrets, no
username). username).
- **The smokes** — the three real-tool postures recorded honestly - **The smokes**, with the three real-tool postures recorded honestly
(required/conditionally-skipped with reason); nothing silently (required/conditionally-skipped with reason); nothing silently
green. green.
- **Wave-2 gate's booked items** — every one discharged or explicitly - **Wave-2 gate's booked items**: every one discharged or explicitly
re-booked (decline tracing = landed; acceptor vocabulary = landed re-booked (decline tracing = landed; acceptor vocabulary = landed
in `w3-tunnel-listen`; identity landing = discharged; the in `w3-tunnel-listen`; identity landing = discharged; the
inherited-auth-agent-open watch item = unchanged or re-read at any inherited-auth-agent-open watch item = unchanged or re-read at any
pin touch). pin touch).
- **Docs honesty** — module docs name what each replaced stub now - **Docs honesty**: module docs name what each replaced stub now
is, what remains wave-4/5 material, and any new upstream notes is, what remains wave-4/5 material, and any new upstream notes
(portable-pty's PTY-path exit shape; the `take_call_connection` (portable-pty's PTY-path exit shape; the `take_call_connection`
semantics reading). semantics reading).
@@ -84,103 +84,102 @@ booked (remediation before wave 4 decomposes).
- docs/plans/implementation.md (wave 3 section + this gate's role) - docs/plans/implementation.md (wave 3 section + this gate's role)
- docs/architecture/mapping.md, auth.md, client.md (the mirror), - docs/architecture/mapping.md, auth.md, client.md (the mirror),
overview.md, open-questions.md, ADRs 002/003/004/005/006 overview.md, open-questions.md, ADRs 002/003/004/005/006
- docs/research/poc-3-mapping-bakeoff.md (stages A/C/D — the evidence - docs/research/poc-3-mapping-bakeoff.md (stages A/C/D, the evidence
the contract tests re-land) the contract tests re-land)
## Notes ## Notes
### Gate reading (line by line, 2026-10-29; commits 270365e…3d0253d) ### Gate reading (line by line, 2026-10-29; commits 270365e…3d0253d)
- **mapping.md line-by-line** ✓ — each spine row read against the - **mapping.md line-by-line** ✓, each spine row read against the
landed code: the session/pty/shell/pipe rows (translation bridge, landed code: the session/pty/shell/pipe rows (translation bridge,
OQ-08 argv conventions, pipe-less shell serves pipe-mode), the git OQ-08 argv conventions, pipe-less shell serves pipe-mode), the git
exec row (full grammar, no-oracle validation, alkgit dispatch), the exec row (full grammar, no-oracle validation, alkgit dispatch), the
direct-tcpip / direct-streamlocal rows (route-1 bridge), the direct-tcpip / direct-streamlocal rows (route-1 bridge), the
forwarded-tcpip row (echo args + bound port, teardown-at-birth on forwarded-tcpip row (echo args + bound port, teardown-at-birth on
correlation miss), the global listen rows (verbatim resource + correlation miss), the global listen rows (verbatim resource +
bound-port out-param — one doc correction landed: §The listen dial bound-port out-param; one doc correction landed there: §The listen dial
now states the port appending, the code's `host:port` form), the now states the port appending, the code's `host:port` form), the
request-level table, and the derivation contract rules 1–6 in request-level table, and the derivation contract rules 1–6 in
`derive_exit` (incl. `i32::MIN` via `checked_abs` → 255, `derive_exit` (incl. `i32::MIN` via `checked_abs` → 255,
`core_dumped` never, rule 3's table entry unreachable by `core_dumped` never, rule 3's table entry unreachable by
construction, the PTY-path rule-5 limit documented). The decline construction, the PTY-path rule-5 limit documented). The decline
matrix is green per feature build through the harness (per-build matrix is green per feature build through the harness (per-build
cfg-pinned modules; unset-field rows — feature on, handle unset — cfg-pinned modules; the unset-field rows (feature on, handle unset)
present in every featured build). present in every featured build).
- **ADR-006** ✓ — route-1 bridge (accept only after the fabric open; - **ADR-006** ✓, route-1 bridge (accept only after the fabric open;
`ConnectFailed` on failure; the wave-2 stub retired into the real `ConnectFailed` on failure; the wave-2 stub retired into the real
decline), exec-only git (no subsystem dispatch anywhere in the decline), exec-only git (no subsystem dispatch anywhere in the
grammar; git service names decline), parse-only dispatch (no grammar; git service names decline), parse-only dispatch (no
detect residue), spawn-and-return re-pinned over the real serving detect residue), spawn-and-return re-pinned over the real serving
paths (the wave-2 mechanics rows still green; the inline listen paths (the wave-2 mechanics rows still green; the inline listen
dial is the one recorded exception, bounded by dial is the one recorded exception, bounded by
`LISTEN_DIAL_TIMEOUT` — wire-necessity reasoning documented). `LISTEN_DIAL_TIMEOUT` with the wire-necessity reasoning documented).
- **ADR-003** ✓ — per-build matrices; honest advertisement - **ADR-003** ✓, per-build matrices; honest advertisement
unchanged; the sftp feature still gates nothing and its rows unchanged; the sftp feature still gates nothing and its rows
decline in every build (OQ-13); x11/auth-agent never-features decline in every build (OQ-13); x11/auth-agent never-features
untouched; feature-to-dep isolation (`cargo tree` proven in untouched; feature-to-dep isolation (`cargo tree` proven in
`w3-seam-config`). `w3-seam-config`).
- **Auth/identity** ✓ — the resolved identity observed at every seam: - **Auth/identity** ✓, the resolved identity observed at every seam:
alktty (the ownership witness row), alkgit (the authorize/ deny alktty (the ownership witness row), alkgit (the authorize/ deny
row), tunnels (the CF-005/CF-006 establisher + ACL rows); row), tunnels (the CF-005/CF-006 establisher + ACL rows);
`set_identity`/`take_call_connection` appears **nowhere in src/** `set_identity`/`take_call_connection` appears **nowhere in src/**
(the detach decision recorded, the upstream ask booked — see below (the detach decision recorded, the upstream ask booked (see below
for final disposition); the wave-2 `auth_succeeded` parked item for final disposition); the wave-2 `auth_succeeded` parked item
discharged as ride-along store; no username anywhere (the discharged as ride-along store; no username anywhere (the
safety-scan row). safety-scan row).
- **OQ resolutions in code** ✓ — OQ-08 (unset-declines, both field - **OQ resolutions in code** ✓, OQ-08 (unset-declines, both field
classes, `wrapper ++ [cmd]` verbatim-append), OQ-09 (env declined classes, `wrapper ++ [cmd]` verbatim-append), OQ-09 (env declined
unconditionally), OQ-10 (derivation rules + PTY-path limit unconditionally), OQ-10 (derivation rules + PTY-path limit
documented), OQ-11 (runtime-Unix gates compile-everywhere with documented), OQ-11 (runtime-Unix gates compile-everywhere with
dial-call-witness-at-zero off-Unix rows), OQ-13 (sftp rows dial-call-witness-at-zero off-Unix rows), OQ-13 (sftp rows
unchanged in every build). unchanged in every build).
- **Bounded resources** ✓ — the cap across the fabric window (admit - **Bounded resources** ✓, with the cap across the fabric window (admit
at the hook, release-before-reject on fabric failure, disjoint at the hook, release-before-reject on fabric failure, disjoint
saturating release sites verified per row), the listener saturating release sites verified per row), the listener
bookkeeping bounded by a dedicated `max_concurrent_channels`-ceiling bookkeeping bounded by a dedicated `max_concurrent_channels`-ceiling
counter, the route map bounded by construction (minted only at counter, the route map bounded by construction (minted only at
accepted listens, removed at cancels + session teardown — the accepted listens, removed at cancels + session teardown, and the
`Drop` really removes from the shared router), the control-row `Drop` really removes from the shared router), the control-row
surfaces bounded (`try_send`, never awaited on the packet loop), surfaces bounded (`try_send`, never awaited on the packet loop),
the bridge adapters bounded (mpsc 64, 64 KiB duplexes, 16 KiB read the bridge adapters bounded (mpsc 64, 64 KiB duplexes, 16 KiB read
buffers, `MAX_CHUNK_LEN` frame gates). No unbounded shape found in buffers, `MAX_CHUNK_LEN` frame gates). No unbounded shape found in
any serving path. any serving path.
- **Tracing** ✓ — the uniform event vocabulary at every - **Tracing** ✓, the uniform event vocabulary at every
decline/serve site (row + feature_build + channel_id + class + decline/serve site (row + feature_build + channel_id + class +
reason + exec_route; declines `debug`, the one `warn` the reason + exec_route; declines `debug`, the one `warn` the
serving-failure arm), safety posture pinned by scan (no username, serving-failure arm), safety posture pinned by scan (no username,
no fingerprint, no secret ever in an event). no fingerprint, no secret ever in an event).
- **The smokes** ✓ — git clone 2.43 / `ssh -L` + curl / `ssh -tt` - **The smokes** ✓, git clone 2.43 / `ssh -L` + curl / `ssh -tt`
pty / `ssh -R` + curl: all four landed and green in this pty / `ssh -R` + curl: all four landed and green in this
environment; every one carries a check-gated skip-with-reason environment; every one carries a check-gated skip-with-reason
posture, nothing silently green. posture, nothing silently green.
- **Wave-2 booked items** ✓ — decline tracing landed - **Wave-2 booked items** ✓, with decline tracing landed
(`w3-decline-tracing`); the acceptor vocabulary landed (`w3-decline-tracing`); the acceptor vocabulary landed
(`w3-tunnel-listen`: harness accept hook + per-arrival cap); the (`w3-tunnel-listen`: harness accept hook + per-arrival cap); the
deferred identity-landing assertion discharged (each seam rides the deferred identity-landing assertion discharged (each seam rides the
resolved identity, wire-witnessed); the inherited resolved identity, wire-witnessed); the inherited
`auth-agent`/`forwarded-streamlocal`-open watch item unchanged (no `auth-agent`/`forwarded-streamlocal`-open watch item unchanged (no
pin touch this wave). pin touch this wave).
- **Docs honesty** ✓ — each replaced stub's module docs name what it - **Docs honesty** ✓, as each replaced stub's module docs name what it
now is and what remains wave-4/5; the `set_identity` reading, the now is and what remains wave-4/5; the `set_identity` reading, the
resource-naming convention, the inline-dial bound, and resource-naming convention, the inline-dial bound, and
portable-pty's PTY-path exit shape are all recorded in module docs portable-pty's PTY-path exit shape are all recorded in module docs
(one overbroad Drop doc corrected inline, below). (one overbroad Drop doc corrected inline, below).
### Interpretations (flagged at decomposition) — all confirmed ### Interpretations (flagged at decomposition), all confirmed
1. **Whole-`Option` struct per seam** (vs flat `Option` fields) — 1. **Whole-`Option` struct per seam** (vs flat `Option` fields),
confirmed: each seam is consumed atomically by exactly one seam confirmed: each seam is consumed atomically by exactly one seam
call; the flat single-field tunnel handle stays flat. Recorded in call; the flat single-field tunnel handle stays flat. Recorded in
`w3-seam-config`. `w3-seam-config`.
2. **The server-side sftp bullet already discharged in wave 2** — 2. **The server-side sftp bullet already discharged in wave 2**, confirmed: the rows are in `tests/dispatch_core.rs` per build.
confirmed: the rows are in `tests/dispatch_core.rs` per build. 3. **The `-R` listen flow's biggest open closed without a fork**,
3. **The `-R` listen flow's biggest open closed without a fork** —
confirmed: the topology closed over the published alkcall 0.10.1 / confirmed: the topology closed over the published alkcall 0.10.1 /
alktunnels 0.4.1 surfaces via the `TunnelListenSeam` alktunnels 0.4.1 surfaces via the `TunnelListenSeam`
course-correction; a scratch probe (published crates only) proved course-correction; a scratch probe (published crates only) proved
the flow before landing; nothing forked or absorbed. the flow before landing; nothing forked or absorbed.
4. **The wave-2 booked items' wave-3 homes** — confirmed as above. 4. **The wave-2 booked items' wave-3 homes**, confirmed as above.
### Minor issues found and fixed inline (this session) ### Minor issues found and fixed inline (this session)
@@ -190,47 +189,47 @@ Source/doc fixes (all commit-tested; suite re-run green after):
construction** (the gate's one real fix): alkcall's construction** (the gate's one real fix): alkcall's
`OperationRegistry::register` silently *replaces* an existing op `OperationRegistry::register` silently *replaces* an existing op
entry, contradicting the `install_forwarded_surface` docs' recorded entry, contradicting the `install_forwarded_surface` docs' recorded
fail-closed claim — a second adapter over the same shared seam fail-closed claim: a second adapter over the same shared seam
registry would silently steal the first adapter's arrival routes. registry would silently steal the first adapter's arrival routes.
`install_forwarded_surface` now pre-checks the op `install_forwarded_surface` now pre-checks the op
(`registry.registration(OP_TUNNEL_FORWARDED)`) and refuses with (`registry.registration(OP_TUNNEL_FORWARDED)`) and refuses with
`AdapterError::ListenRegistration`; new wire-level row `AdapterError::ListenRegistration`; new wire-level row
`a_second_adapter_over_a_shared_listen_registry_fails_construction` `a_second_adapter_over_a_shared_listen_registry_fails_construction`
pins both constructs/refuse. pins both constructs/refuse.
- **channel_cap.rs** — the bridge-failure release doc said "after the - **channel_cap.rs**, the bridge-failure release doc said "after the
reject is answered"; the code (and the pinned test) release reject is answered"; the code (and the pinned test) release
*before* the reject. Doc corrected. *before* the reject. Doc corrected.
- **handler.rs** — the `Drop` doc claimed "every spawned serving task - **handler.rs**, the `Drop` doc claimed "every spawned serving task
watches" the teardown signal; scoping corrected (the session rows watches" the teardown signal; scoping corrected (the session rows
watch; the git task ends through the duplex, bounded by alkgit's watch; the git task ends through the duplex, bounded by alkgit's
`session_wall_clock`). The cancel-form doc's recorded ordering `session_wall_clock`). The cancel-form doc's recorded ordering
(router-before-map) was inverted vs the code; corrected, with the (router-before-map) was inverted vs the code; corrected, with the
post-cancel-race caveat recorded. post-cancel-race caveat recorded.
- **tunnel_map.rs** — the "never half-serves" bookkeeping claim now - **tunnel_map.rs**, the "never half-serves" bookkeeping claim now
records the one-arrival caveat: an arrival that cloned its route records the one-arrival caveat: an arrival that cloned its route
just before a cancel completes independently (never half-open; just before a cancel completes independently (never half-open;
sshd parity). sshd parity).
- **tty_map.rs** — stale ADR cross-ref on the exit chunk (alktty - **tty_map.rs**, a stale ADR cross-ref on the exit chunk (alktty
ADR-005 → ADR-004 on the alknet ADR-055 lineage). ADR-005 → ADR-004 on the alknet ADR-055 lineage).
- **lib.rs** — the crate-level wave-state doc still said "wave 2 - **lib.rs**, the crate-level wave-state doc still said "wave 2
decomposes after this gate"; updated to the landed state. decomposes after this gate"; updated to the landed state.
- **tests/dispatch_core.rs** — the stale - **tests/dispatch_core.rs**, the stale
`unknown_git_services_route_to_the_pipe_row` name/doc superseded by `unknown_git_services_route_to_the_pipe_row` name/doc superseded by
the w3 decision ("off the pipe row"); renamed, verdicts preserved the w3 decision ("off the pipe row"); renamed, verdicts preserved
(the wire shape coincides — why it never failed). (the wire shape coincides, which is why it never failed).
- **handler.rs (git row)** — a pty-then-git-exec in a combined build - **handler.rs (git row)**, a pty-then-git-exec in a combined build
left the stored pty entry until close; the git take now drops it left the stored pty entry until close; the git take now drops it
(the pipe row's consumption shape). (the pipe row's consumption shape).
- **mapping.md** — the §listen-dial resource wording corrected to the - **mapping.md**, the §listen-dial resource wording corrected to the
code's assembled form (address verbatim + tcp port appended; code's assembled form (address verbatim + tcp port appended;
`0` stays `0`). `0` stays `0`).
### Booked findings (not remediation this gate — wave-4 material) ### Booked findings (not remediation this gate, wave-4 material)
1. **The git row's status-before-close is honored by scheduling, not 1. **The git row's status-before-close is honored by scheduling, not
by construction** (the gate's only structural moderate): by construction** (the gate's only structural moderate):
`serve_duplex_resolved` splits the channel stream internally and `serve_duplex_resolved` splits the channel stream internally and
its session halves drop when the serve returns — russh's its session halves drop when the serve returns, so russh's
`ChannelCloseOnDrop` rx-half emits a server→client `ChannelCloseOnDrop` rx-half emits a server→client
`CHANNEL_CLOSE` *inside* the seam call, before `CHANNEL_CLOSE` *inside* the seam call, before
`serve_git_row`'s explicit `exit_status_request`. Today the `serve_git_row`'s explicit `exit_status_request`. Today the
@@ -240,11 +239,11 @@ Source/doc fixes (all commit-tested; suite re-run green after):
implicit close first and silently drop the report (russh no-ops implicit close first and silently drop the report (russh no-ops
`exit_status_request` for an already-closed channel). A fix wants `exit_status_request` for an already-closed channel). A fix wants
a seam/shape change (split halves into the seam, or queue the a seam/shape change (split halves into the seam, or queue the
report while the stream is alive) — beyond this gate's inline report while the stream is alive), beyond this gate's inline
budget; book a wave-4 hardening item. (The tty rows are NOT in budget; book a wave-4 hardening item. (The tty rows are NOT in
this class: their report precedes any close-contracting drop in this class: their report precedes any close-contracting drop in
one task.) one task.)
2. **The `channel-not-held` guard lacks wire test rows** — a 2. **The `channel-not-held` guard lacks wire test rows**, a
documented decline class on all three rows (git/tty pipe/shell), documented decline class on all three rows (git/tty pipe/shell),
constructible on the wire (a second exec on a served channel), constructible on the wire (a second exec on a served channel),
but no row asserts it in any build; mapping.md's every-row but no row asserts it in any build; mapping.md's every-row
@@ -255,14 +254,14 @@ Source/doc fixes (all commit-tested; suite re-run green after):
the wire (only the teardown signal is); the Closed/Break the wire (only the teardown signal is); the Closed/Break
no-exit-chunk close shape has no wire row; the tty stdout no-exit-chunk close shape has no wire row; the tty stdout
`write_all` carries no deadline (backpressure bounded, liveness `write_all` carries no deadline (backpressure bounded, liveness
covered by inactivity timeout — record as a known limit only). covered by inactivity timeout; record it as a known limit only).
4. **Defensive `ListenRouter` token-collision pre-check** (minor): 4. **Defensive `ListenRouter` token-collision pre-check** (minor):
`register` silently replaces on token collision; safety rests on `register` silently replaces on token collision; safety rests on
the fabric's ADR-008 mint discipline. A fail-closed the fabric's ADR-008 mint discipline. A fail-closed
decline-the-listen-on-collision is a cheap wave-4 addition. decline-the-listen-on-collision is a cheap wave-4 addition.
5. **Upstream asks to file** (booked with their owners, not this 5. **Upstream asks to file** (booked with their owners, not this
crate): the alktunnels/alkcall "non-detaching post-construction crate): the alktunnels/alkcall "non-detaching post-construction
identity seam" note (`w3-tunnel-direct`'s recorded decision — the identity seam" note (`w3-tunnel-direct`'s recorded decision, under which the
door keeps the no-detach posture until upstream proposes a seam), door keeps the no-detach posture until upstream proposes a seam),
and mapping.md's recorded event-vocabulary nuance for the and mapping.md's recorded event-vocabulary nuance for the
unreachable mismatch arms (`correlation-miss` naming). unreachable mismatch arms (`correlation-miss` naming).
+57 -57
View File
@@ -1,6 +1,6 @@
--- ---
id: review-wave-4 id: review-wave-4
name: Wave 4 gate — the client-direction review name: "Wave 4 gate: the client-direction review"
status: completed status: completed
depends_on: [w4-dial, w4-op-rows, w4-reverse-listen, w4-lifecycle, w4-git-exit-close, w4-channel-not-held-rows, w4-tty-coverage, w4-router-collision] depends_on: [w4-dial, w4-op-rows, w4-reverse-listen, w4-lifecycle, w4-git-exit-close, w4-channel-not-held-rows, w4-tty-coverage, w4-router-collision]
scope: narrow scope: narrow
@@ -12,49 +12,49 @@ level: review
## Description ## Description
The wave-4 review gate. The client direction completes the The wave-4 review gate. The client direction completes the
two-direction door — wave 5 builds the full matrix and the release on two-direction door, and wave 5 builds the full matrix and the release on
both directions, and the published crate freezes this surface. The both directions, and the published crate freezes this surface. The
gate reads the landed client direction against the architecture line gate reads the landed client direction against the architecture line
by line: by line:
- **client.md line-by-line** — §The dial surface (key source - **client.md line-by-line**, reading it section by section: §The dial surface (key source
decrypt-at-dial fail-closed; the policy at `check_server_key`; the decrypt-at-dial fail-closed; the policy at `check_server_key`; the
bounded knobs with the keepalive 15 s / 3 posture), §Op rows (each bounded knobs with the keepalive 15 s / 3 posture), §Op rows (each
table row read against the landed code — the POC-3 §D.3 mechanics table row read against the landed code, the POC-3 §D.3 mechanics
pinned in code + rows), §The acceptor (served only under a pinned in code + rows), §The acceptor (served only under a
registered feature's acceptor; unregistered/unfeatured declined registered feature's acceptor; unregistered/unfeatured declined
cleanly — never accept-and-drop; late-install across a live cleanly, never accept-and-drop; late-install across a live
connection), §Lifecycle primitives (explicitly cancellable handles; connection), §Lifecycle primitives (explicitly cancellable handles;
`Disconnect::ByApplication`; **no reconnect/restore policy in the `Disconnect::ByApplication`; **no reconnect/restore policy in the
crate** — the boundary held). crate**, a boundary held).
- **ADR-003 symmetry** — the same features gate both directions; the - **ADR-003 symmetry**, where the same features gate both directions; the
per-build matrices green client-side (the acceptor rows per build); per-build matrices green client-side (the acceptor rows per build);
honest advertisement unchanged; the sftp placeholder declines in honest advertisement unchanged; the sftp placeholder declines in
every build both directions (OQ-13); x11/auth-agent never-features every build both directions (OQ-13); x11/auth-agent never-features
untouched. untouched.
- **ADR-005 / OQ-05 / OQ-07** — decrypt-at-dial with every - **ADR-005 / OQ-05 / OQ-07**, decrypt-at-dial with every
`KeySourceError` posture a no-dial outcome; the policy seam `KeySourceError` posture a no-dial outcome; the policy seam
consumed at the hook (crate default connects to nothing; no consumed at the hook (crate default connects to nothing; no
accept-all built-in); the `Zeroizing` wrap posture intact. accept-all built-in); the `Zeroizing` wrap posture intact.
- **The wave-3 gate's booked items 1–4** — every one discharged or - **The wave-3 gate's booked items 1–4**, every one discharged or
explicitly re-booked: the git status-before-close by construction explicitly re-booked: the git status-before-close by construction
(`w4-git-exit-close`), the channel-not-held wire rows (`w4-git-exit-close`), the channel-not-held wire rows
(`w4-channel-not-held-rows`), the tty coverage minors (`w4-channel-not-held-rows`), the tty coverage minors
(`w4-tty-coverage`), the router token-collision pre-check (`w4-tty-coverage`), the router token-collision pre-check
(`w4-router-collision`). Item 5 (the upstream asks) verified still (`w4-router-collision`). Item 5 (the upstream asks) verified still
booked with their owners — not absorbed silently. booked with their owners, not absorbed silently.
- **Bounded resources** — no new unbounded shape on the client - **Bounded resources**, with no new unbounded shape on the client
surfaces: the acceptor registry bounded, the listener bookkeeping surfaces: the acceptor registry bounded, the listener bookkeeping
bounded, the pumps bounded (the convention-9 read over every new bounded, the pumps bounded (the convention-9 read over every new
serving/pumping path). serving/pumping path).
- **Tracing** — the uniform event vocabulary across the new - **Tracing**, the uniform event vocabulary across the new
client-side decline/serve paths; the safety posture held (no client-side decline/serve paths; the safety posture held (no
secrets, no key material, no username). secrets, no key material, no username).
- **The smokes** — the client-direction real-tool postures recorded - **The smokes**, with the client-direction real-tool postures recorded
honestly (required/conditionally-skipped with reason); nothing honestly (required/conditionally-skipped with reason); nothing
silently green; anything named wave-5 e2e territory named silently green; anything named wave-5 e2e territory named
explicitly. explicitly.
- **Docs honesty** — module docs name what each new surface is, what - **Docs honesty**: module docs name what each new surface is, what
remains wave-5 material, and any new upstream notes (the russh remains wave-5 material, and any new upstream notes (the russh
client-hook shapes at this pin; the acceptor-registry shape client-hook shapes at this pin; the acceptor-registry shape
decision; the DialConfig disposition). decision; the DialConfig disposition).
@@ -96,75 +96,75 @@ match `docs/plans/implementation.md`'s decomposition record; every
task file carries its completion records and checked acceptance task file carries its completion records and checked acceptance
criteria. criteria.
- **client.md line-by-line** ✓ — read against the landed code: - **client.md line-by-line** ✓, read against the landed code:
§The dial surface (`client.rs` — the four-step fail-closed §The dial surface (`client.rs`, the four-step fail-closed
sequence: decrypt-at-dial key load, the bounded russh `Config` sequence: decrypt-at-dial key load, the bounded russh `Config`
with the keepalive 15 s / 3 posture and the always-set wall clock, with the keepalive 15 s / 3 posture and the always-set wall clock,
the policy at `check_server_key`, the single publickey-only auth; the policy at `check_server_key`, the single publickey-only auth;
the sanctioned `username` `DialConfig` amendment recorded honestly the sanctioned `username` `DialConfig` amendment recorded honestly
— a required parameter, no crate default, never identity, never (a required parameter, no crate default, never identity, never
logged); §Op rows (`client_ops.rs` — every table row present: the logged); §Op rows, covering `client_ops.rs`, where every table row is present: the
exec/pty/shell rows under `session`, the direct rows under exec/pty/shell rows under `session`, the direct rows under
`tunnel`, the git exec row under `git` over the shared grammar's `tunnel`, the git exec row under `git` over the shared grammar's
construction arm, the sftp placeholder in every build; POC-3 construction arm, the sftp placeholder in every build; POC-3
§D.3 mechanics pinned in code + rows — `exec()` not §D.3 mechanics pinned in code plus rows (`exec()` not `request_exec`,
`request_exec`, the `wait()`-drain Success/Failure, split-halves `request_exec`, the `wait()`-drain Success/Failure, split-halves
wait-pumps for the exit-consuming rows and straight wait-pumps for the exit-consuming rows and straight
`into_stream` for the exit-less, the fire-and-forget controls `into_stream` for the exit-less, the fire-and-forget controls
sent from the live pump, no queue consumption before any sent from the live pump, no queue consumption before any
conversion); §The acceptor (`client_accept.rs` + the conversion); §The acceptor (`client_accept.rs` + the
`client_handler.rs` hooks — per-type fixed slots, refuse-never- `client_handler.rs` hooks, being per-type fixed slots, refuse-never-
replace, arrival-time reads, decline-not-buffer, late-install replace, arrival-time reads, decline-not-buffer, late-install
over the live connection, listen rows gated pre-wire, the two over the live connection, listen rows gated pre-wire, the two
dedicated counters against the one ceiling); §Lifecycle dedicated counters against the one ceiling); §Lifecycle
primitives (`client_accept.rs` handles + `client.rs` §Lifecycle primitives (`client_accept.rs` handles + `client.rs` §Lifecycle
posture — explicit cancels, `Disconnect::ByApplication` the only posture, with explicit cancels, `Disconnect::ByApplication` the only
clean path, drop-is-never-the-clean-path row-pinned both ways, clean path, drop-is-never-the-clean-path row-pinned both ways,
the no-reconnect/restore-policy boundary stated in module docs; the no-reconnect/restore-policy boundary stated in module docs;
the crate owns no policy). client.md's landing blocks (acceptor/ the crate owns no policy). client.md's landing blocks (acceptor/
lifecycle/op-row shapes recorded at landing) are in the doc. lifecycle/op-row shapes recorded at landing) are in the doc.
- **ADR-003 symmetry** ✓ — the features gate both directions - **ADR-003 symmetry** ✓, as the features gate both directions
(`Cargo.toml` unchanged: `session`/`git`/`tunnel`/`sftp`, (`Cargo.toml` unchanged: `session`/`git`/`tunnel`/`sftp`,
`default = []`); the client exports ride the same gates `default = []`); the client exports ride the same gates
(`RemoteSession` under `session`, `TunnelChannel` + the acceptor (`RemoteSession` under `session`, `TunnelChannel` + the acceptor
surface + `ReverseListener` under `tunnel`, `GitPackSession` surface + `ReverseListener` under `tunnel`, `GitPackSession`
under `git`, the sftp row feature-free); the per-build matrices under `git`, the sftp row feature-free); the per-build matrices
are green (the default build's 16 binaries + per-feature/ are green (the default build's 16 binaries plus per-feature/
`--all-features`; the unfeatured builds' fail-closed wire rows `--all-features`; the unfeatured builds' fail-closed wire rows
ride `tests/client_dial.rs`'s decline-all row — the ride `tests/client_dial.rs`'s decline-all row, and the
`client_handler.rs` hooks are cfg-declined in every build); `client_handler.rs` hooks are cfg-declined in every build);
honest advertisement unchanged; x11/agent never-features honest advertisement unchanged; x11/agent never-features
untouched on both directions. untouched on both directions.
- **ADR-005 / OQ-05 / OQ-07** ✓ — decrypt-at-dial with every - **ADR-005 / OQ-05 / OQ-07** ✓, decrypt-at-dial with every
`KeySourceError` posture a no-dial outcome (`DialError::KeySource` `KeySourceError` posture a no-dial outcome (`DialError::KeySource`
preserved per-variant; `error.rs` taxonomy rows); the policy seam preserved per-variant; `error.rs` taxonomy rows); the policy seam
consumed at the hook (`client_handler.rs::check_server_key` — the consumed at the hook (`client_handler.rs::check_server_key`, where the
fingerprint rides the crate's single path, `PresentedHostKey` fingerprint rides the crate's single path, `PresentedHostKey`
carries host/port, the verdict passes through; `RejectAll` carries host/port, the verdict passes through; `RejectAll`
connects to nothing, no accept-all built-in); the wave-1 connects to nothing, no accept-all built-in); the wave-1
`Zeroizing` wrap posture intact (`key_source.rs`, untouched). `Zeroizing` wrap posture intact (`key_source.rs`, untouched).
- **The wave-3 gate's booked items 1–4** ✓ — all discharged: - **The wave-3 gate's booked items 1–4** ✓, all discharged:
item 1 (`w4-git-exit-close`) — the split-halves seam duplex item 1 (`w4-git-exit-close`), the split-halves seam duplex
(`ChannelHalveReader` over the owned close-free read half; no (`ChannelHalveReader` over the owned close-free read half; no
drop path ever messages the wire) with the by-construction drop path ever messages the wire) with the by-construction
property wire-pinned and the row falsifying the old shape property wire-pinned and the row falsifying the old shape
deterministic-red-under-revert; item 2 deterministic-red-under-revert; item 2
(`w4-channel-not-held-rows`) — the guard's wire rows on all (`w4-channel-not-held-rows`), the guard's wire rows on all
three served rows per build, vocabulary-asserting; item 3 three served rows per build, vocabulary-asserting; item 3
(`w4-tty-coverage`) — the `channel_close`-fired cancel path (`w4-tty-coverage`), the `channel_close`-fired cancel path
wire-witnessed, the Closed/Break no-exit-chunk shape row-pinned wire-witnessed, the Closed/Break no-exit-chunk shape row-pinned
(Break's frame classification unit-rowed, wire-unreachable, (Break's frame classification unit-rowed, wire-unreachable,
recorded), the `write_all` no-deadline limit recorded in module recorded), the `write_all` no-deadline limit recorded in module
docs; item 4 (`w4-router-collision`) — the register-refuses docs; item 4 (`w4-router-collision`), the register-refuses
pre-check with the error-surface decision recorded honestly (the pre-check with the error-surface decision recorded honestly (the
wire-time decline disposition, not a taxonomy variant that would wire-time decline disposition, not a taxonomy variant that would
kill the session). **Item 5 verified still booked with its kill the session). **Item 5 verified still booked with its
owners, not absorbed**: no `set_identity`/`take_call_connection` owners and not absorbed**: no `set_identity`/`take_call_connection`
anywhere in `src/` code (doc references only, recording the anywhere in `src/` code (doc references only, recording the
no-detach posture + the upstream ask), and mapping.md's no-detach posture + the upstream ask), and mapping.md's
correlation-miss event-vocabulary nuance records stand. correlation-miss event-vocabulary nuance records stand.
- **Bounded resources** ✓ (convention 9 read over every new - **Bounded resources** ✓, with the convention-9 read over every new
client serving/pumping path) — the acceptor registry bounded by client serving/pumping path showing the acceptor registry bounded by
construction (two slots); the reverse-listen count and the construction (two slots); the reverse-listen count and the
accepted-open handoff count are two dedicated `ChannelCap` accepted-open handoff count are two dedicated `ChannelCap`
counters against the same `Limits::max_concurrent_channels` counters against the same `Limits::max_concurrent_channels`
@@ -174,37 +174,37 @@ criteria.
launch-drain cap, russh windows verbatim, the always-on wall launch-drain cap, russh windows verbatim, the always-on wall
clock); the git row's command/repo gates answer pre-wire. One clock); the git row's command/repo gates answer pre-wire. One
gap found and fixed inline (below). gap found and fixed inline (below).
- **Tracing** ✓ — the client-side decline paths ride the uniform - **Tracing** ✓, with the client-side decline paths riding the uniform
vocabulary (`decline_open` with `no-acceptor` / `cap-exceeded` / vocabulary (`decline_open` with `no-acceptor` / `cap-exceeded` /
`never-feature`; the skeleton's decline rows event-pinned); the `never-feature`; the skeleton's decline rows event-pinned); the
safety posture held — structurally no username/fingerprint/secret safety posture held, as structurally no username/fingerprint/secret
parameter exists on any event helper (the `&'static str` parameter exists on any event helper (the `&'static str`
vocabulary); the client op rows' typed surfaces carry outcomes vocabulary); the client op rows' typed surfaces also carry outcomes
without events (the recorded asymmetry decision). without events (the recorded asymmetry decision).
- **The smokes** ✓ — three recorded honestly: the alkssh-client ↔ - **The smokes** ✓, three recorded honestly: the alkssh-client ↔
alkssh-server dial smoke (`tests/client_dial.rs`), the client-exec alkssh-server dial smoke (`tests/client_dial.rs`), the client-exec
↔ server-session-rows smoke (`tests/client_ops.rs`, check-gated ↔ server-session-rows smoke (`tests/client_ops.rs`, check-gated
on `sh`), the full-circle reverse-listen smoke on `sh`), the full-circle reverse-listen smoke
(`tests/tunnel_mapping.rs`); real-`sshd` e2e named wave-5 (`tests/tunnel_mapping.rs`); real-`sshd` e2e named wave-5
territory explicitly in each task's records — nothing silently territory explicitly in each task's records, with nothing silently
green, nothing dropped. green, nothing dropped.
- **Docs honesty** ✓ — module docs name each new surface and its - **Docs honesty** ✓, as module docs name each new surface and its
remaining wave-5 material (the fake-`sshd` posture, the remaining wave-5 material (the fake-`sshd` posture, the
DialConfig amendment, the russh client hook shapes at this pin, DialConfig amendment, the russh client hook shapes at this pin,
the acceptor-registry shape decision, the recorded wire facts — the acceptor-registry shape decision, and the recorded wire facts
the port-0-only mint, the drop/is-closed semantics); one stale the port-0-only mint, the drop/is-closed semantics); one stale
crate-level sentence fixed inline (below). crate-level sentence fixed inline (below).
### Minor issues found and fixed inline (this session) ### Minor issues found and fixed inline (this session)
- **The accepted-open handoff counter's panic-unsafe release** (the - **The accepted-open handoff counter's panic-unsafe release**, the
gate's one code fix): the counter released only after gate's one code fix: the counter released only after
`acceptor.accept(...)` returned — an assembly acceptor panicking `acceptor.accept(...)` returned, so an assembly acceptor panicking
inside `accept` stranded one held slot for the connection's inside `accept` would strand one held slot for the connection's
lifetime (a bounded-resource leak path on the new cap). Fixed lifetime, a bounded-resource leak path on the new cap. Fixed
with a panic-safe guard (`HandoffCapGuard` in with a panic-safe guard (`HandoffCapGuard` in
`src/client_accept.rs`): the release fires at the handoff task's `src/client_accept.rs`): the release fires at the handoff task's
end, on completion *or unwind*; the recorded release-site wording end, whether on completion or unwind; the recorded release-site wording
updated in the module docs, the trait docs, and updated in the module docs, the trait docs, and
`client_handler.rs`'s two references; a unit row `client_handler.rs`'s two references; a unit row
(`the_handoff_guard_releases_on_unwind`) pins the unwind release. (`the_handoff_guard_releases_on_unwind`) pins the unwind release.
@@ -214,23 +214,23 @@ criteria.
`w4-lifecycle`; the same staleness class the wave-3 gate fixed `w4-lifecycle`; the same staleness class the wave-3 gate fixed
inline). Corrected to the landed state. inline). Corrected to the landed state.
### Interpretations (flagged at decomposition) — all confirmed ### Interpretations (flagged at decomposition), all confirmed
1. **The fail-closed client Handler skeleton in `w4-dial`, the 1. **The fail-closed client Handler skeleton in `w4-dial`, the
registry in `w4-reverse-listen`** — confirmed: the skeleton's registry in `w4-reverse-listen`**, confirmed: the skeleton's
decline-all default is row-pinned in every build, and the decline-all default is row-pinned in every build, and the
registry grows exactly the two forwarded hooks; the split kept registry grows exactly the two forwarded hooks; the split kept
both surfaces reviewable. both surfaces reviewable.
2. **The fake-`sshd` client-direction vehicle in `tests/common`** — 2. **The fake-`sshd` client-direction vehicle in `tests/common`**,
confirmed: vanilla-parity, real loopback TCP, one dial per confirmed: vanilla-parity, real loopback TCP, one dial per
instance, and the harness's `ForwardedAcceptor` vocabulary was instance, and the harness's `ForwardedAcceptor` vocabulary was
grown (not forked) into `client_accept.rs`'s real surface. grown (not forked) into `client_accept.rs`'s real surface.
3. **No `DialConfig` course-correction anticipated** — confirmed, 3. **No `DialConfig` course-correction anticipated** was confirmed,
with the one sanctioned exception recorded in-task (the required with the one sanctioned exception recorded in-task (the required
`username` — the USERAUTH_REQUEST's structural requirement; the `username`, the USERAUTH_REQUEST's structural requirement; the
amendment follows the `w3-seam-config` pattern and is honestly amendment follows the `w3-seam-config` pattern and is honestly
documented). documented).
4. **The hardening tasks ordered by single-writer needs** — 4. **The hardening tasks ordered by single-writer needs** were
confirmed: `tests/git_mapping.rs` then `tests/tty_mapping.rs` confirmed: `tests/git_mapping.rs` then `tests/tty_mapping.rs`
sequencing held, no cross-task file conflicts in the landed sequencing held, no cross-task file conflicts in the landed
history. history.
@@ -259,9 +259,9 @@ and the lifecycle primitives with the drop/clean-close boundary
row-pinned both ways and the no-policy posture stated. All three row-pinned both ways and the no-policy posture stated. All three
interpretations confirmed except as recorded (all favorable); the interpretations confirmed except as recorded (all favorable); the
wave-3 booked items 1–4 verified discharged and item 5 verified wave-3 booked items 1–4 verified discharged and item 5 verified
still booked upstream — nothing absorbed. The full verification set still booked upstream, with nothing absorbed. The full verification set
is green. Two minors fixed inline (the handoff counter's is green. Two minors fixed inline (the handoff counter's
panic-unsafe release — the one code fix, guard + unit row; a stale panic-unsafe release, the one code fix with its guard and unit row; a stale
crate-level wave-state doc sentence). No structural findings; wave crate-level wave-state doc sentence). No structural findings; wave
5 decomposition is unblocked and should absorb nothing beyond its 5 decomposition is unblocked and should absorb nothing beyond its
own plan (the decline matrix, real-tool e2e, fuzzing, docs, the own plan (the decline matrix, real-tool e2e, fuzzing, docs, the
@@ -269,12 +269,12 @@ publish).
## Summary ## Summary
**Verdict: green** (2026-10-29) — see Notes for the full gate **Verdict: green** (2026-10-29); see Notes for the full gate
reading. All eight wave-4 tasks verified against the client.md/ADR reading. All eight wave-4 tasks verified against the client.md/ADR
contract surfaces line by line; the wave-3 gate's booked items 1–4 contract surfaces line by line; the wave-3 gate's booked items 1–4
discharged, item 5 verified still booked upstream; the full discharged, item 5 verified still booked upstream; the full
verification set + `taskgraph validate` green. Two inline fixes verification set + `taskgraph validate` green. Two inline fixes
landed with the gate: the accepted-open handoff counter's landed with the gate: the accepted-open handoff counter's
panic-safe release (`HandoffCapGuard` — the gate's one code fix) panic-safe release (`HandoffCapGuard`, the gate's one code fix)
and the stale crate-level wave-state doc sentence in `lib.rs`. and the stale crate-level wave-state doc sentence in `lib.rs`.
Wave 5 decomposition is unblocked. Wave 5 decomposition is unblocked.
+45 -45
View File
@@ -1,6 +1,6 @@
--- ---
id: review-wave-5 id: review-wave-5
name: Wave 5 gate — the release review name: "Wave 5 gate: the release review"
status: completed status: completed
depends_on: [w5-decline-matrix, w5-e2e-server, w5-e2e-client, w5-fuzz-harness, w5-release-readiness] depends_on: [w5-decline-matrix, w5-e2e-server, w5-e2e-client, w5-fuzz-harness, w5-release-readiness]
scope: narrow scope: narrow
@@ -11,40 +11,40 @@ level: review
## Description ## Description
The wave-5 review gate — the release review that closes the plan The wave-5 review gate, the release review that closes the plan
(`docs/plans/implementation.md` §Wave 5: "then **the publish of (`docs/plans/implementation.md` §Wave 5: "then **the publish of
`alkssh 0.1`** — the gate review that closes the plan"). The gate `alkssh 0.1`**, which is the gate review that closes the plan"). The gate
stays a **pure review**; the publish is its own task (`w5-publish`) stays a **pure review**; the publish is its own task (`w5-publish`)
executing only over a green verdict. The gate reads: executing only over a green verdict. The gate reads:
- **The decline-matrix suite** — the coverage-audit table read - **The decline-matrix suite**, reading the coverage-audit table
against `mapping.md`'s matrix table cell by cell: every row × against `mapping.md`'s matrix table cell by cell: every row ×
every build it can appear in × both directions pinned; the sftp every build it can appear in × both directions pinned; the sftp
placeholder rows and the never-features explicit in every build; placeholder rows and the never-features explicit in every build;
the matrix runner in the verification list. the matrix runner in the verification list.
- **The e2e postures** — both directions' real-tool rows read: - **The e2e postures**, reading both directions' real-tool rows:
nothing silently green (every skip reasoned with the tool/version nothing silently green (every skip reasoned with the tool/version
cause), every e2e finding discharged or explicitly booked with an cause), every e2e finding discharged or explicitly booked with an
owner (never absorbed); the real-`sshd` client-direction rows owner, never absorbed; the real-`sshd` client-direction rows
verified against the reverse-runner shape. verified against the reverse-runner shape.
- **The fuzz corpus gate** — the sidecar's layout read against the - **The fuzz corpus gate**, reading the sidecar's layout against the
alkgit convention (detached runner, committed seeds + no-op-diff alkgit convention (detached runner, committed seeds + no-op-diff
generator, the shared-library corpus replay in the verification generator, the shared-library corpus replay in the verification
list); the campaign logs read (no unresolved crash/OOM/timeout); list); the campaign logs read (no unresolved crash/OOM/timeout);
the exposure shape read (no non-`doc(hidden)` surface growth). the exposure shape read (no non-`doc(hidden)` surface growth).
- **Release readiness** — rustdoc warning-free × builds; README/ - **Release readiness**: rustdoc warning-free across builds; README/
CHANGELOG read for honesty; the packaged file list against the CHANGELOG read for honesty; the packaged file list against the
package policy; the manifest sanity; **the freeze inventory read package policy; the manifest sanity; **the freeze inventory read
against the landed code** — the list of surfaces the publish makes against the landed code**, checking the list of surfaces the publish makes
one-way (the mapping contracts, the honest-advertisement posture, one-way (the mapping contracts, the honest-advertisement posture,
the decline-matrix behavior, the fingerprint format, the russh the decline-matrix behavior, the fingerprint format, the russh
pin's wire posture) confirmed accurate, because `w5-publish` pin's wire posture) confirmed accurate, because `w5-publish`
executes immediately after this gate. executes immediately after this gate.
- **The plan's closure conditions** — the scope-authority checklist - **The plan's closure conditions**, verifying the scope-authority checklist
(the complete two-direction door shipped; the assembly out; no (the complete two-direction door shipped; the assembly out; no
binary; single crate) verified against the landed state; the binary; single crate) verified against the landed state; the
upstream asks (the wave-3 gate's item 5 lineage) verified still upstream asks (the wave-3 gate's item 5 lineage) verified still
booked with their owners — nothing absorbed silently through the booked with their owners, with nothing absorbed silently through the
release. release.
Verdict: green (`w5-publish` unblocked) or findings booked Verdict: green (`w5-publish` unblocked) or findings booked
@@ -80,27 +80,27 @@ Verdict: green (`w5-publish` unblocked) or findings booked
## Notes ## Notes
**Verdict: green.** `w5-publish` is unblocked. Every gate read was **Verdict: green.** `w5-publish` is unblocked. Every gate read was
re-executed live at this commit (2026-10-30, post-`ab5236e`) — nothing re-executed live at this commit (2026-10-30, post-`ab5236e`), and nothing
was taken solely from the task records. No blocking issues; no code was taken solely from the task records. No blocking issues; no code
findings; nothing rose to booked-remediation level. Notes below. findings; nothing rose to booked-remediation level. Notes below.
### The matrix suite ✓ ### The matrix suite ✓
Every test name the coverage audit pins resolves in its binary (the Every test name the coverage audit pins resolves in its binary (the
full inventory grep-verified this session — all 26 `dispatch_core` full inventory grep-verified this session, with all 26 `dispatch_core`
rows / all 8 `tunnel_mapping` + `git_mapping` + client-side rows rows / all 8 `tunnel_mapping` + `git_mapping` + client-side rows
named in the audit table exist), and `mapping.md` §Decline matrix named in the audit table exist), and `mapping.md` §Decline matrix
traces cell by cell onto audit rows: the none build declines traces cell by cell onto audit rows: the none build declines
everything (`every_channel_open_declines_in_a_default_build` + the everything (`every_channel_open_declines_in_a_default_build` + the
shared x11/agent/cancel/listen rows); the git build's declines shared x11/agent/cancel/listen rows); the git build's declines
(pty/shell — the gap-closed pty row, plain exec, tunnel opens, (pty/shell including the gap-closed pty row, then plain exec, tunnel opens,
unreachable session requests, subsystems, unknown services) and its unreachable session requests, subsystems, unknown services) and its
one serve (git-parseable exec); the session build's symmetric shape one serve (git-parseable exec); the session build's symmetric shape
(git-parseable exec declines, wrapper-unset declines, seam-unset (git-parseable exec declines, wrapper-unset declines, seam-unset
pty/shell); the tunnel build's serve/decline split incl. the seam-unset pty/shell); the tunnel build's serve/decline split incl. the seam-unset
listen forms and the `ConnectFailed` fabric reject; the sftp slot's listen forms and the `ConnectFailed` fabric reject; the sftp slot's
unchanged-underlying-build row (the subsystem row can't appear where unchanged-underlying-build row (the subsystem row can't appear where
session channels decline at open — the audit row 8's reasoning holds; session channels decline at open, so the audit row 8's reasoning holds;
the client-side placeholder pins every build); the never-features the client-side placeholder pins every build); the never-features
pinned in every build both directions, with the `auth-agent` open's pinned in every build both directions, with the `auth-agent` open's
pin-bound-unreachable record read and accepted (the russh-0.65 pin-bound-unreachable record read and accepted (the russh-0.65
@@ -108,25 +108,25 @@ pre-handler decline is part of the pin per ADR-002). The runner
(`scripts/matrix.sh`, the script vehicle decided + recorded) is in (`scripts/matrix.sh`, the script vehicle decided + recorded) is in
AGENTS.md's verification list. AGENTS.md's verification list.
### Both e2e postures ✓ — nothing silently green ### Both e2e postures ✓, with nothing silently green
`tests/e2e_server.rs` (12 rows) and `tests/e2e_client.rs` (7 rows) `tests/e2e_server.rs` (12 rows) and `tests/e2e_client.rs` (7 rows)
both read: every real-tool row is check-gated with both read: every real-tool row is check-gated with
`support::skip(row, reason)` prints — tool absent/short, spawn `support::skip(row, reason)` prints, covering a tool absent or short, spawn
refusal, and (client fixture) never-listening with the server's `-e` refusal, and (client fixture) never-listening with the server's `-e`
log tail — never a silent pass; every row is deadline-bounded end to log tail, so never a silent pass; every row is deadline-bounded end to
end; every decline row additionally pins the door-side decline end; every decline row additionally pins the door-side decline
event (the e2e_server rows). The task records' findings are all event (the e2e_server rows). The task records' findings are all
e2e-graph-local flake hardening (per-row workdirs, the monotonic e2e-graph-local flake hardening (per-row workdirs, the monotonic
port allocator, the fabric settle, the event poll) — corroborated by port allocator, the fabric settle, the event poll), corroborated by
the 10-consecutive-run claims and this session's full-matrix run. the 10-consecutive-run claims and this session's full-matrix run.
The e2e_client fixture/gating decisions (tunnel+session gating for The e2e_client fixture/gating decisions (tunnel+session gating for
the stays-usable row, every-build rows for the feature-free ops) are the stays-usable row, every-build rows for the feature-free ops) are
recorded in the task and read honest. On this host every row ran recorded in the task and read honest. On this host every row ran
(OpenSSH 9.6p1, git 2.43, curl 8.5, sftp present) — the matrix log (OpenSSH 9.6p1, git 2.43, curl 8.5, sftp present), and the matrix log
carries the real-tool rows green, no skip prints fired. carries the real-tool rows green, no skip prints fired.
### The fuzz sidecar ✓ — convention held, logs clean ### The fuzz sidecar ✓, with the convention held and logs clean
Layout matches the alkgit convention (`fuzz/README.md`: thin Layout matches the alkgit convention (`fuzz/README.md`: thin
`fuzz_target!` wrappers, the stable-clean `shared/` library, the `fuzz_target!` wrappers, the stable-clean `shared/` library, the
@@ -134,15 +134,15 @@ detached runner, the nightly pin inside `fuzz/`, committed seeds +
the deterministic generator, grown corpora/artifacts/coverage the deterministic generator, grown corpora/artifacts/coverage
gitignored). `gen_fuzz_seeds.py` re-run this session was a no-op gitignored). `gen_fuzz_seeds.py` re-run this session was a no-op
diff on the committed set. The corpus gate re-ran green (11/11, diff on the committed set. The corpus gate re-ran green (11/11,
exit 0). The four final campaign logs read clean — `oom/timeout/ exit 0). The four final campaign logs read clean: `oom/timeout/
crash: 0/0/0`, `exiting: 0` (exec_parse 43M+ execs / fingerprint crash: 0/0/0`, `exiting: 0` (exec_parse 43M+ execs / fingerprint
12M+ / key_decode 5M+ / terminal_params 9M+); the two first-campaign 12M+ / key_decode 5M+ / terminal_params 9M+); the two first-campaign
crash logs (exec_parse exit 77, key_decode exit 77) are exactly the crash logs (exec_parse exit 77, key_decode exit 77) are exactly the
recorded in-task findings — the harness oracle's double-`/` strip recorded in-task findings, the harness oracle's double-`/` strip
(harness-side, fixed) and the ssh-key PPK header echo (the real (harness-side, fixed) and the ssh-key PPK header echo (the real
finding: `decode_key_arm` now carries a crate-owned fixed message; finding: `decode_key_arm` now carries a crate-owned fixed message;
re-ran clean). The exposure shape matches the record: one re-ran clean). The exposure shape matches the record: one
no-exposure target (fingerprint — fully public ingress) + three no-exposure target (fingerprint, with its fully public ingress) plus three
`#[doc(hidden)]` production-surface re-exports; the root crate's `#[doc(hidden)]` production-surface re-exports; the root crate's
package excludes the sidecar (verified in the package list) and the package excludes the sidecar (verified in the package list) and the
root workspace excludes it (workspace + package excludes in root workspace excludes it (workspace + package excludes in
@@ -151,40 +151,40 @@ root workspace excludes it (workspace + package excludes in
### Release readiness ✓ ### Release readiness ✓
`cargo doc --no-deps` warning-free on the default build (matrix run) `cargo doc --no-deps` warning-free on the default build (matrix run)
**and** `--all-features` — re-run this session, exit 0, zero **and** `--all-features`, re-run this session with exit 0 and zero
warnings. README (feature graph + fail-closed posture + custody warnings. README (feature graph + fail-closed posture + custody
summary + the doctest-mirrored examples) and the CHANGELOG 0.1.0 summary + the doctest-mirrored examples) and the CHANGELOG 0.1.0
founding entry both read honest and match the landed surface. The founding entry both read honest and match the landed surface. The
publish dry-run re-ran clean (76 files packaged, verification publish dry-run re-ran clean (76 files packaged, verification
compile passed, upload aborted by dry run); `cargo package --list` compile passed, upload aborted by dry run); `cargo package --list`
re-verified against the package policy — exactly `src/` (20), re-verified against the package policy: exactly `src/` (20),
`tests/` (+common +vectors), `docs/architecture/` (the full `tests/` (+common +vectors), `docs/architecture/` (the full
contract: the four spec docs + ADRs 001–006 + README + the tracker), contract: the four spec docs + ADRs 001–006 + README + the tracker),
`README`, `CHANGELOG`, both licenses, the manifests, and `README`, `CHANGELOG`, both licenses, the manifests, and
`scripts/matrix.sh`; none of the excluded surface (AGENTS.md, `scripts/matrix.sh`; none of the excluded surface (AGENTS.md,
`.opencode/`, plans/research/reviews/sdd_process, tasks/, `fuzz/`) `.opencode/`, plans/research/reviews/sdd_process, tasks/, `fuzz/`)
appears. Manifest sanity: version 0.1.0 (the manifest value — appears. Manifest sanity: version 0.1.0 (the manifest value;
`w5-publish` verifies, no bump), edition 2024, `rust-version` 1.89, `w5-publish` verifies that no bump is needed), edition 2024, `rust-version` 1.89,
license + both files, the pins exactly as ADR-002 records them license + both files, the pins exactly as ADR-002 records them
(russh 0.65 `default-features = false` + `aws-lc-rs`; alkcall 0.10; (russh 0.65 `default-features = false` + `aws-lc-rs`; alkcall 0.10;
alkvault 0.1; the optional siblings 0.8.1 / 0.1.0 / 0.4.1), the alkvault 0.1; the optional siblings 0.8.1 / 0.1.0 / 0.4.1), the
feature graph exactly ADR-003's flat shape, `default = []`. feature graph exactly ADR-003's flat shape, `default = []`.
### The freeze inventory ✓ — accurate against the landed code ### The freeze inventory ✓, accurate against the landed code
Item by item: (1) the mapping contracts — the route-1 bridge, Item by item: (1) the mapping contracts, i.e. the route-1 bridge,
exec-only grammar + verdicts, parse-only dispatch + exec-only grammar + verdicts, parse-only dispatch +
decline-before-alloc, translation bridge + exit rules 1–6, the client decline-before-alloc, translation bridge + exit rules 1–6, the client
planes — as executed in `dispatch.rs`/`tty_map.rs`/`git_map.rs`/ planes, as executed in `dispatch.rs`/`tty_map.rs`/`git_map.rs`/
`tunnel_map.rs`/`client_ops.rs` and line-by-line read green at the `tunnel_map.rs`/`client_ops.rs` and line-by-line read green at the
wave-3/4 gates; (2) the honest-advertisement posture — `MethodSet` wave-3/4 gates; (2) the honest-advertisement posture, where `MethodSet`
exactly `{PublicKey}` at the adapter's `Config` construction exactly `{PublicKey}` at the adapter's `Config` construction
(`adapter.rs:135`), verified by its own unit row; (3) the (`adapter.rs:135`), verified by its own unit row; (3) the
decline-matrix behavior — the `OpenDeclineReason` vocabulary and decline-matrix behavior, the `OpenDeclineReason` vocabulary and
decline shapes as pinned by the matrix suite; (4) the fingerprint decline shapes as pinned by the matrix suite; (4) the fingerprint
format — `SHA256:` + unpadded base64 via `HashAlg::Sha256` format, `SHA256:` plus unpadded base64 via `HashAlg::Sha256`
(`fingerprint.rs:29`), the single path both the auth bridge and the (`fingerprint.rs:29`), the single path both the auth bridge and the
policy pin consume; (5) the russh pin's wire posture — 0.65.0 + policy pin consume; (5) the russh pin's wire posture, 0.65.0 plus
`aws-lc-rs`, `des`/`dsa` never, per `Cargo.toml`. The inventory as `aws-lc-rs`, `des`/`dsa` never, per `Cargo.toml`. The inventory as
recorded in `w5-release-readiness.md` matches the landed code; the recorded in `w5-release-readiness.md` matches the landed code; the
publish freezes exactly those surfaces. publish freezes exactly those surfaces.
@@ -194,25 +194,25 @@ publish freezes exactly those surfaces.
The complete two-direction door shipped (server stack + all four The complete two-direction door shipped (server stack + all four
mapping rows + the decline placeholder; dial + op rows + the `-R` mapping rows + the decline placeholder; dial + op rows + the `-R`
leg + lifecycle primitives); the assembly stays out (no leg + lifecycle primitives); the assembly stays out (no
identity/ACL/fabric policy owned by the crate — seams only); no identity/ACL/fabric policy owned by the crate, seams only); no
binary (no `[[bin]]` target; the package ships one lib); single binary (no `[[bin]]` target; the package ships one lib); single
crate (single-member workspace, no tracks); nothing absorbed crate (single-member workspace, no tracks); nothing absorbed
silently — the upstream-ask inventory verified still booked with its silently, with the upstream-ask inventory verified still booked with its
owners: owners:
- wave-3 gate item 5 (two asks): the alktunnels/alkcall - wave-3 gate item 5 (two asks): the alktunnels/alkcall
non-detaching post-construction identity seam (the door keeps the non-detaching post-construction identity seam (the door keeps the
no-detach posture — no `set_identity`-style surface appeared in no-detach posture, and no `set_identity`-style surface appeared in
`src/` through the release), and mapping.md's correlation-miss `src/` through the release), and mapping.md's correlation-miss
event-vocabulary nuance. event-vocabulary nuance.
- The wave-5 fuzz finding's `ssh-key` PPK parse-echo ask — recorded - The wave-5 fuzz finding's `ssh-key` PPK parse-echo ask, recorded
in `w5-fuzz-harness.md` as booked with the russh/ssh-key owners; in `w5-fuzz-harness.md` as booked with the russh/ssh-key owners;
the actual tracker filing is owner-territory and stays listed the actual tracker filing is owner-territory and stays listed
here so it cannot be absorbed at the publish. here so it cannot be absorbed at the publish.
### Observations (non-blocking, no action required) ### Observations (non-blocking, no action required)
- The packaged file list includes `tests/vectors/alpha.key` — the - The packaged file list includes `tests/vectors/alpha.key`, the
committed ed25519 test-fixture private key. Intentional (wave 1's committed ed25519 test-fixture private key. Intentional (wave 1's
fixtures, public test material, no custody posture applies) and fixtures, public test material, no custody posture applies) and
consistent across the family's sibling crates; noted here so the consistent across the family's sibling crates; noted here so the
@@ -224,8 +224,8 @@ owners:
### Verification (re-run at this commit, all green, 2026-10-30) ### Verification (re-run at this commit, all green, 2026-10-30)
`scripts/matrix.sh`: `cargo test` × 6 builds (none / git / session / `scripts/matrix.sh`: `cargo test` × 6 builds (none / git / session /
tunnel / sftp-only / --all-features — 151 / 195 / 210 / 201 / 151 / tunnel / sftp-only / --all-features, with 151 / 195 / 210 / 201 /
281 tests passed, 0 failed; all integration binaries + doctests 151 / 281 tests passed, 0 failed; all integration binaries + doctests
included) · clippy (--all-targets -D warnings) ✓ · clippy included) · clippy (--all-targets -D warnings) ✓ · clippy
(--all-features --all-targets -D warnings) ✓ · `cargo fmt --check` (--all-features --all-targets -D warnings) ✓ · `cargo fmt --check`
✓ · `cargo doc --no-deps` ✓ (plus `--all-features` re-run ✓) · the ✓ · `cargo doc --no-deps` ✓ (plus `--all-features` re-run ✓) · the
@@ -236,13 +236,13 @@ completion statuses).
## Summary ## Summary
**Verdict: green — `w5-publish` unblocked.** The wave-5 release gate **Verdict: green, `w5-publish` unblocked.** The wave-5 release gate
re-executed every read live at the gate commit: the coverage audit re-executed every read live at the gate commit: the coverage audit
traced cell by cell onto `mapping.md`'s decline-matrix table with traced cell by cell onto `mapping.md`'s decline-matrix table with
every pinned test resolving in its binary and the `sftp` every pinned test resolving in its binary and the `sftp`
no-op-feature fact + never-features verified in every build both no-op-feature fact + never-features verified in every build both
directions; both e2e binaries read with nothing silently green (the directions; both e2e binaries read with nothing silently green (the
whole suite ran on this host — no skips fired); the fuzz sidecar whole suite ran on this host, so no skips fired); the fuzz sidecar
held the alkgit convention with the seeds no-op, the corpus gate held the alkgit convention with the seeds no-op, the corpus gate
green, and all four final campaigns clean (the two first-campaign green, and all four final campaigns clean (the two first-campaign
crashes exactly the recorded fixed findings); release readiness crashes exactly the recorded fixed findings); release readiness
@@ -253,6 +253,6 @@ become one-way at the publish; the plan's closure conditions
verified (two-direction door, assembly out, no binary, single verified (two-direction door, assembly out, no binary, single
crate, upstream asks still booked). One real code fix was already crate, upstream asks still booked). One real code fix was already
discharged in-task (the `ssh-key` PPK echo → crate-owned decode discharged in-task (the `ssh-key` PPK echo → crate-owned decode
message). No blocking issues and no findings — this is the plan's message). No blocking issues and no findings, and this is the plan's
last review; `w5-publish` (tag `alkssh-v0.1.0`, the verification last review; `w5-publish` (tag `alkssh-v0.1.0`, the verification
set, the publish, the plan closure) closes it. set, the publish, the plan closure) closes it.
+37 -35
View File
@@ -1,6 +1,6 @@
--- ---
id: w1-config id: w1-config
name: Wave 1 — the config surface (serve-config, dial-config, Limits) name: "Wave 1: the config surface (serve-config, dial-config, Limits)"
status: completed status: completed
depends_on: [w1-manifest, w1-key-sources, w1-host-key-policy] depends_on: [w1-manifest, w1-key-sources, w1-host-key-policy]
scope: moderate scope: moderate
@@ -11,37 +11,38 @@ level: implementation
## Description ## Description
Land the pure configuration types both directions implement against — Land the pure configuration types both directions implement against,
the shapes every later wave's constructors take: the shapes every later wave's constructors take:
- **Serve-config** (server direction, `mapping.md`/`overview.md`) — - **Serve-config** (server direction, `mapping.md`/`overview.md`), the
the assembly-supplied serving configuration: the OQ-08 fields assembly-supplied serving configuration: the OQ-08 fields
(**shell argv** and **exec wrapper argv**, each `Option<Vec<String>>` (**shell argv** and **exec wrapper argv**, each `Option<Vec<String>>`;
— unset → its row declines with a clean error, never a fallback, no when unset, its row declines with a clean error, never a fallback,
crate default; the wrapper form is platform-neutral), the no crate default; the wrapper form is platform-neutral), the
`IdentityProvider` handle (`Arc<dyn IdentityProvider>` — the `IdentityProvider` handle (`Arc<dyn IdentityProvider>`, which the
assembly injects it; the crate never resolves identities itself), assembly injects; the crate never resolves identities itself), the
the host-key source handle (the wave-1 key-source seam), the tunnel host-key source handle (the wave-1 key-source seam), the tunnel
`ChannelClient` handle (the route-1 bridge's family-fabric entry — `ChannelClient` handle (the route-1 bridge's family-fabric entry,
`Option`, absent → tunnel rows decline even under the feature), and held as an `Option`; when absent, tunnel rows decline even under
the `Limits`. `cwd`/`env` in `NegotiateRequest` are assembly-supplied the feature), and the `Limits`. `cwd`/`env` in `NegotiateRequest`
serve-config values, never wire-derived (OQ-09 posture). are assembly-supplied serve-config values, never wire-derived
- **Dial-config** (client direction, `client.md` §The dial surface) — (OQ-09 posture).
address, key source (the wave-1 seam — never static keys, never env - **Dial-config** (client direction, `client.md` §The dial surface):
address, key source (the wave-1 seam; never static keys, never env
vars), the `HostKeyPolicy` handle, and the bounded-resource knobs: vars), the `HostKeyPolicy` handle, and the bounded-resource knobs:
`keepalive_interval`/`keepalive_max` (15 s / 3 default posture per `keepalive_interval`/`keepalive_max` (15 s / 3 default posture per
the reverse-runner evidence), `inactivity_timeout`, window/packet the reverse-runner evidence), `inactivity_timeout`, window/packet
knobs. knobs.
- **`Limits`** — the bounded-resources budget model (`overview.md`'s - **`Limits`**: the bounded-resources budget model (`overview.md`'s
table): the in-crate max-concurrent-channels cap, exec-parse length table): the in-crate max-concurrent-channels cap, exec-parse length
caps (consumed by the wave-3 grammar), and the russh knob mapping caps (consumed by the wave-3 grammar), and the russh knob mapping
with documented default postures — `max_auth_attempts` default 10 with documented default postures (`max_auth_attempts` default 10,
(sized for the counting `none`-probe, ADR-004 decision 6), sized for the counting `none`-probe per ADR-004 decision 6;
`window_size`/`maximum_packet_size`/`channel_buffer_size` set, `window_size`/`maximum_packet_size`/`channel_buffer_size` set;
`inactivity_timeout` set (assembly-tunable). Every session carries `inactivity_timeout` set, assembly-tunable). Every session carries
it; no unbounded loops, no unbounded buffers. it; no unbounded loops, no unbounded buffers.
Types only — no russh `Config` construction (wave 2), no dialing Types only: no russh `Config` construction (wave 2), no dialing
(wave 4). The OQ-08 unset-declines semantics live in the type shape (wave 4). The OQ-08 unset-declines semantics live in the type shape
(`Option` fields + doc contracts), not in construction-time (`Option` fields + doc contracts), not in construction-time
validation. validation.
@@ -71,7 +72,7 @@ validation.
section) section)
- docs/architecture/client.md §The dial surface - docs/architecture/client.md §The dial surface
- docs/architecture/open-questions.md (OQ-08, OQ-09 resolutions) - docs/architecture/open-questions.md (OQ-08, OQ-09 resolutions)
- docs/architecture/decisions/004-auth-dispositions.md (decision 6 — - docs/architecture/decisions/004-auth-dispositions.md (decision 6:
the `max_auth_attempts` default) the `max_auth_attempts` default)
## Notes ## Notes
@@ -82,25 +83,25 @@ validation.
> >
> - **`Limits` is the single budget model both directions carry** > - **`Limits` is the single budget model both directions carry**
> (`Copy`, plain tuning data): the in-crate caps > (`Copy`, plain tuning data): the in-crate caps
> (`max_concurrent_channels` 64, `max_exec_command_len` 8192 — > (`max_concurrent_channels` 64, `max_exec_command_len` 8192, with
> numbers chosen here, documented as assembly-tunable posture > numbers chosen here and documented as assembly-tunable posture
> constants) plus the russh-knob mapping (`max_auth_attempts` 10 > constants) plus the russh-knob mapping (`max_auth_attempts` 10
> per ADR-004 decision 6; `window_size` 2 MiB / `maximum_packet_size` > per ADR-004 decision 6; `window_size` 2 MiB / `maximum_packet_size`
> 32 KiB / `channel_buffer_size` 100 — russh's own defaults, kept; > 32 KiB / `channel_buffer_size` 100, russh's own defaults kept;
> `inactivity_timeout` 600 s carried as plain `Duration` so the > `inactivity_timeout` 600 s carried as plain `Duration` so the
> "set (assembly-tunable)" posture is structural — wave 2/4 map it > "set (assembly-tunable)" posture is structural, with wave 2/4
> to russh's `Option` as always-`Some`; > mapping it to russh's `Option` as always-`Some`;
> `keepalive_interval`/`keepalive_max` Some(15 s)/3 riding inside as > `keepalive_interval`/`keepalive_max` Some(15 s)/3 riding inside as
> the client-direction posture, satisfying the dial-config > the client-direction posture, satisfying the dial-config
> criterion without a duplicate knob set). russh's rekey `Limits` > criterion without a duplicate knob set). russh's rekey `Limits`
> stay russh-enforced at their RFC-4253 defaults and are not > stay russh-enforced at their RFC-4253 defaults and are not
> carried (documented; wave-2 material). > carried (documented; wave-2 material).
> - **Dial-config carries the knobs via its `Limits` field** — the > - **Dial-config carries the knobs via its `Limits` field**: the
> criterion's "keepalive/inactivity/window knobs with the > criterion's "keepalive/inactivity/window knobs with the
> documented defaults" read as the budget model riding the config; > documented defaults" read as the budget model riding the config;
> the accepted criterion on `Limits` itself pins that placement, > the accepted criterion on `Limits` itself pins that placement,
> so a second knob set on `DialConfig` would have duplicated state. > so a second knob set on `DialConfig` would have duplicated state.
> - **No `Default` for the two configs** — the required handles > - **No `Default` for the two configs**: the required handles
> (`Arc<dyn IdentityProvider>`, > (`Arc<dyn IdentityProvider>`,
> `Arc<dyn HostKeySource>`, `Arc<dyn KeySource>`, > `Arc<dyn HostKeySource>`, `Arc<dyn KeySource>`,
> `Arc<dyn HostKeyPolicy>`) have no crate-defaultable shape > `Arc<dyn HostKeyPolicy>`) have no crate-defaultable shape
@@ -111,12 +112,12 @@ validation.
> + doc contracts**; no construction-time validation (the > + doc contracts**; no construction-time validation (the
> `error.rs` no-config-error contract). Two request-time check > `error.rs` no-config-error contract). Two request-time check
> helpers exist (`shell_configured`/`exec_wrapper_configured`, > helpers exist (`shell_configured`/`exec_wrapper_configured`,
> plus `tunnel_configured`) for the wave-2 dispatch to call — > plus `tunnel_configured`) for the wave-2 dispatch to call,
> reading the `Option`, never validating. > reading the `Option`, never validating.
> - **`cwd`/`env` (OQ-09 posture) carried on `ServeConfig`** as > - **`cwd`/`env` (OQ-09 posture) carried on `ServeConfig`** as
> `Option<PathBuf>` / `BTreeMap<String, String>` (empty defaults; > `Option<PathBuf>` / `BTreeMap<String, String>` (empty defaults;
> never wire-derived — doc contract). > never wire-derived; doc contract).
> - **`channel_client` is not feature-gated** — alkcall is a base > - **`channel_client` is not feature-gated**: alkcall is a base
> dependency and a field presence is not a capability; the `tunnel` > dependency and a field presence is not a capability; the `tunnel`
> feature gate applies at wave 2 on top (absent → decline even > feature gate applies at wave 2 on top (absent → decline even
> under the feature; unfeatured → decline too). Doc contract > under the feature; unfeatured → decline too). Doc contract
@@ -124,7 +125,7 @@ validation.
> - Verified the alkcall 0.10 surfaces in-source (registry): > - Verified the alkcall 0.10 surfaces in-source (registry):
> `core::auth::IdentityProvider` is a sync, > `core::auth::IdentityProvider` is a sync,
> `Send + Sync + 'static` trait (dyn-compatible with no > `Send + Sync + 'static` trait (dyn-compatible with no
> async-trait help on alkssh's side — `ChannelClient` and the > async-trait help on alkssh's side; `ChannelClient` and the
> `NoopIdentityProvider` test fixture ride > `NoopIdentityProvider` test fixture ride
> `alkcall::channels::client` / `alkcall::core::auth`), and the > `alkcall::channels::client` / `alkcall::core::auth`), and the
> russh 0.65.0 knob names (`window_size`, `maximum_packet_size`, > russh 0.65.0 knob names (`window_size`, `maximum_packet_size`,
@@ -137,7 +138,8 @@ validation.
> Config surface landed: `src/config.rs` with `Limits` (in-crate > Config surface landed: `src/config.rs` with `Limits` (in-crate
> caps: `max_concurrent_channels` 64, `max_exec_command_len` 8192; > caps: `max_concurrent_channels` 64, `max_exec_command_len` 8192;
> russh knobs: `max_auth_attempts` 10, window 2 MiB, packet 32 KiB, > russh knobs: `max_auth_attempts` 10, window 2 MiB, packet 32 KiB,
> buffer 100, inactivity 600 s, keepalive Some(15 s)/3 — every number > buffer 100, inactivity 600 s, keepalive Some(15 s)/3, with every
> number
> a named posture constant or documented ADR anchor), `ServeConfig` > a named posture constant or documented ADR anchor), `ServeConfig`
> (OQ-08 `shell_argv`/`exec_wrapper_argv` as unset-declines > (OQ-08 `shell_argv`/`exec_wrapper_argv` as unset-declines
> `Option<Vec<String>>`, OQ-09 `session_cwd`/`session_env`, > `Option<Vec<String>>`, OQ-09 `session_cwd`/`session_env`,
+19 -19
View File
@@ -1,6 +1,6 @@
--- ---
id: w1-error-model id: w1-error-model
name: Wave 1 — the error model (fail-closed taxonomy) name: "Wave 1: the error model (fail-closed taxonomy)"
status: completed status: completed
depends_on: [w1-manifest] depends_on: [w1-manifest]
scope: narrow scope: narrow
@@ -12,25 +12,25 @@ level: implementation
## Description ## Description
Land the crate's error taxonomy in `src/error.rs` (one module per file, Land the crate's error taxonomy in `src/error.rs` (one module per file,
re-exported from the crate root — the alktty pattern), `thiserror` re-exported from the crate root, the alktty pattern), `thiserror`
throughout, no catch-alls anywhere (AGENTS.md convention 2): throughout, no catch-alls anywhere (AGENTS.md convention 2):
- **`KeySourceError`** — the ADR-005 decision-4 failure matrix as - **`KeySourceError`**: the ADR-005 decision-4 failure matrix as
distinct variants, the vault's error taxonomy reshaped by the distinct variants, the vault's error taxonomy reshaped by the
adapter, **never collapsed**: `Locked` (vault locked → no dial, and adapter, **never collapsed**: `Locked` (vault locked → no dial, and
no server: a host key that can't load prevents server no server: a host key that can't load prevents server
construction), `Absent` (no blob at the requested path), construction), `Absent` (no blob at the requested path),
`Decryption` (wrong mnemonic / tampered ciphertext — the `Decryption` (wrong mnemonic / tampered ciphertext: the
refuse-at-crypto class), `UnknownKeyVersion` (the vault's refuse-at-crypto class), `UnknownKeyVersion` (the vault's
refuse-before-crypto `InvalidPath` class), `Decode` (plaintext refuse-before-crypto `InvalidPath` class), `Decode` (plaintext
arrived but is not decodable key material — the one-time arrived but is not decodable key material: the one-time
`decode_secret_key` failure). Doc comments carry the ADR semantics `decode_secret_key` failure). Doc comments carry the ADR semantics
per variant, including the fail-closed consequence of each. per variant, including the fail-closed consequence of each.
- Deliberately **not** error types: auth declines (dispositions — - Deliberately **not** error types: auth declines (dispositions, the
`Auth::Reject`, ADR-004; never errors), wire decline reasons `Auth::Reject` of ADR-004; never errors), wire decline reasons
(SSH-shaped responses — wave-2 material), config validation (the (SSH-shaped responses, wave-2 material), config validation (the
OQ-08 fields are `Option` and decline at request time, not OQ-08 fields are `Option` and decline at request time, not
construction time — no config-error type ships unless the config construction time; no config-error type ships unless the config
task finds one needed, flagged for the gate). task finds one needed, flagged for the gate).
The exact variant payload shapes are the task's to pin (minimal per The exact variant payload shapes are the task's to pin (minimal per
@@ -57,27 +57,27 @@ message is the honest content), flagged for the wave-1 gate.
## Notes ## Notes
> Payload shapes pinned (flagged for the wave-1 gate): `Locked` and > Payload shapes pinned (flagged for the wave-1 gate): `Locked` and
> `Absent` are payload-free — no honest content beyond the variant > `Absent` are payload-free, with no honest content beyond the
> itself (the caller knows which source it requested); > variant itself (the caller knows which source it requested);
> `Decryption(String)` and `UnknownKeyVersion(String)` carry the > `Decryption(String)` and `UnknownKeyVersion(String)` carry the
> vault's opaque messages (`VaultServiceError::Encryption` / > vault's opaque messages (`VaultServiceError::Encryption` /
> `InvalidPath` arrive as strings per POC-2 §3 — string is the honest > `InvalidPath` arrive as strings per POC-2 §3, where a string is the
> content). `Decode` carries `String` rather than the typed > honest content). `Decode` carries `String` rather than the typed
> `ssh_key::Error` (`russh::keys::decode_secret_key`'s error type): > `ssh_key::Error` (`russh::keys::decode_secret_key`'s error type):
> the ssh-key RC type is deliberately kept out of the published > the ssh-key RC type is deliberately kept out of the published
> public API — when the RC settles the payload can be re-typed, but > public API; when the RC settles the payload can be re-typed, but
> freezing an RC error type now would be a worse one-way door. The > freezing an RC error type now would be a worse one-way door. The
> module doc comment records the deliberately-not-error classes > module doc comment records the deliberately-not-error classes
> (auth declines = dispositions per ADR-004; wire decline reasons = > (auth declines = dispositions per ADR-004; wire decline reasons =
> wave-2; config validation = none unless w1-config finds a need — > wave-2; config validation = none unless w1-config finds a need,
> the OQ-08 fields decline at request time). No `From<VaultServiceError>` > as the OQ-08 fields decline at request time). No `From<VaultServiceError>`
> blanket impl on purpose: alkvault's taxonomy has variants > blanket impl on purpose: alkvault's taxonomy has variants
> (`Mnemonic`, `Derivation`, `AlreadyUnlocked`, `UnsupportedKeyType`) > (`Mnemonic`, `Derivation`, `AlreadyUnlocked`, `UnsupportedKeyType`)
> that do not map onto the five without collapsing, and the ADR's > that do not map onto the five without collapsing, and the ADR's
> reshape-never-collapse rule kills the conversion; w1-key-sources > reshape-never-collapse rule kills the conversion; w1-key-sources
> maps explicitly at its seam. The module stays engine-blind — no > maps explicitly at its seam. The module stays engine-blind: no
> alkvault or russh types referenced at the error layer. Tests: an > alkvault or russh types referenced at the error layer. Tests: an
> exhaustive `match` (no wildcard — the closed-variant invariant is > exhaustive `match` (no wildcard, so the closed-variant invariant is
> compile-time enforced) plus `Display` string assertions over all > compile-time enforced) plus `Display` string assertions over all
> five variants, `Debug` output, and `Clone` payload carrying. > five variants, `Debug` output, and `Clone` payload carrying.
@@ -89,7 +89,7 @@ message is the honest content), flagged for the wave-1 gate.
> fail-closed consequence (locked → no dial + no server; absent/decryption/ > fail-closed consequence (locked → no dial + no server; absent/decryption/
> unknown-version/decode → no dial; host-key path → no server > unknown-version/decode → no dial; host-key path → no server
> construction throughout). Re-exported from the crate root > construction throughout). Re-exported from the crate root
> (`mod error; pub use error::KeySourceError;` — the alktty pattern). > (`mod error; pub use error::KeySourceError;`, the alktty pattern).
> No catch-all, no `Other/Unknown`, no blanket `From` from the vault's > No catch-all, no `Other/Unknown`, no blanket `From` from the vault's
> taxonomy. Four unit tests (exhaustive match over the enum, > taxonomy. Four unit tests (exhaustive match over the enum,
> `Display`, `Debug`, `Clone`). Verified: `cargo test` (4/4 + doc > `Display`, `Debug`, `Clone`). Verified: `cargo test` (4/4 + doc
+13 -13
View File
@@ -1,6 +1,6 @@
--- ---
id: w1-fingerprint id: w1-fingerprint
name: Wave 1 — fingerprint computation (the contract invariant) name: "Wave 1: fingerprint computation (the contract invariant)"
status: completed status: completed
depends_on: [w1-manifest] depends_on: [w1-manifest]
scope: narrow scope: narrow
@@ -12,23 +12,23 @@ level: implementation
## Description ## Description
Land the fingerprint computation the auth bridge (server) and the Land the fingerprint computation the auth bridge (server) and the
host-key policy path (client) both consume — ADR-004 decision 1's host-key policy path (client) both consume; this is ADR-004
contract invariant in code: decision 1's contract invariant in code:
- Compute a key's fingerprint via the `ssh-key` re-export - Compute a key's fingerprint via the `ssh-key` re-export
(`HashAlg::Sha256`): the `SHA256:` + **unpadded url-safe base64** (`HashAlg::Sha256`): the `SHA256:` + **unpadded url-safe base64**
OpenSSH format. The string format is a **contract invariant** — OpenSSH format. The string format is a **contract invariant**:
registration strings from any standard issuer (`ssh-keygen -lf`, registration strings from any standard issuer (`ssh-keygen -lf`,
alkcall-side computation) must compare equal to what this crate alkcall-side computation) must compare equal to what this crate
computes; parity is proven, not assumed (POC-1 §1). computes; parity is proven, not assumed (POC-1 §1).
- The function takes the russh/ssh-key public-key type and returns the - The function takes the russh/ssh-key public-key type and returns the
string; it is the single fingerprint path in the crate (no string; it is the single fingerprint path in the crate (no
second formatting anywhere — a drift here breaks every identity second formatting anywhere, since a drift here breaks every
resolution silently). identity resolution silently).
- Parity tests against independent-issuer vectors: static ed25519 - Parity tests against independent-issuer vectors: static ed25519
test keys with their `ssh-keygen -lf`-recorded expected fingerprints test keys with their `ssh-keygen -lf`-recorded expected fingerprints
(the POC-1 §1 checkpoint shape). The vectors are committed test (the POC-1 §1 checkpoint shape). The vectors are committed test
fixtures (throwaway test keys — no secret material). fixtures (throwaway test keys; no secret material).
## Acceptance Criteria ## Acceptance Criteria
@@ -42,7 +42,7 @@ contract invariant in code:
## References ## References
- docs/architecture/decisions/004-auth-dispositions.md (decision 1 — - docs/architecture/decisions/004-auth-dispositions.md (decision 1:
the format invariant) the format invariant)
- docs/architecture/auth.md §The bridge (step 2) - docs/architecture/auth.md §The bridge (step 2)
- docs/research/poc-1-auth-bridge.md §1 (the parity checkpoint) - docs/research/poc-1-auth-bridge.md §1 (the parity checkpoint)
@@ -51,7 +51,7 @@ contract invariant in code:
## Notes ## Notes
> Design: `src/fingerprint.rs` exposes `fingerprint(&public_key) -> > Design: `src/fingerprint.rs` exposes `fingerprint(&public_key) ->
> String` — the thin wrapper over the `ssh-key` re-export > String`, the thin wrapper over the `ssh-key` re-export
> (`key.fingerprint(HashAlg::Sha256).to_string()`; russh 0.65 > (`key.fingerprint(HashAlg::Sha256).to_string()`; russh 0.65
> re-exports `ssh_key::{PublicKey, HashAlg}` via `russh::keys`), so > re-exports `ssh_key::{PublicKey, HashAlg}` via `russh::keys`), so
> the *computation and formatting stay ssh-key's own code* and parity > the *computation and formatting stay ssh-key's own code* and parity
@@ -59,14 +59,14 @@ contract invariant in code:
> `alkssh::fingerprint`; a grep over `src/` confirms no second > `alkssh::fingerprint`; a grep over `src/` confirms no second
> formatting path (doc mentions only). The russh 0.65 handler shape > formatting path (doc mentions only). The russh 0.65 handler shape
> feeds exactly this type: `VerifiedPublicKey.public_key: & > feeds exactly this type: `VerifiedPublicKey.public_key: &
> ssh_key::PublicKey` — the auth bridge (wave 2, w1-host-key-policy) > ssh_key::PublicKey`, with the auth bridge (wave 2,
> passes that field straight through. > w1-host-key-policy) passing that field straight through.
> >
> **Fact-check finding (doc correction landed):** the ADR-004 / > **Fact-check finding (doc correction landed):** the ADR-004 /
> auth.md / task wording "unpadded **url-safe** base64" is a misnomer. > auth.md / task wording "unpadded **url-safe** base64" is a misnomer.
> The ssh-key crate's `Fingerprint` Display emits `SHA256:` + > The ssh-key crate's `Fingerprint` Display emits `SHA256:` +
> *unpadded standard* base64 (base64ct `Base64Unpadded` > *unpadded standard* base64 (base64ct `Base64Unpadded`
> = the standard `+/` alphabet), and OpenSSH does the same — the > = the standard `+/` alphabet), and OpenSSH does the same, as the
> committed `ssh-keygen -lf` vectors contain `+` and `/`. The binding > committed `ssh-keygen -lf` vectors contain `+` and `/`. The binding
> invariant was never "url-safe"; it is byte-parity with standard > invariant was never "url-safe"; it is byte-parity with standard
> issuers, which POC-1 proved and the committed vectors now pin. > issuers, which POC-1 proved and the committed vectors now pin.
@@ -85,7 +85,7 @@ contract invariant in code:
## Summary ## Summary
> Landed `src/fingerprint.rs` — the crate's single fingerprint path > Landed `src/fingerprint.rs`, the crate's single fingerprint path
> (`alkssh::fingerprint`), delegating computation/formatting to the > (`alkssh::fingerprint`), delegating computation/formatting to the
> ssh-key re-export behind russh 0.65's keys module with > ssh-key re-export behind russh 0.65's keys module with
> `HashAlg::Sha256`. Parity proven, not assumed: 4 committed > `HashAlg::Sha256`. Parity proven, not assumed: 4 committed
+14 -13
View File
@@ -1,6 +1,6 @@
--- ---
id: w1-host-key-policy id: w1-host-key-policy
name: Wave 1 — the HostKeyPolicy seam (OQ-05, policy-blind) name: "Wave 1: the HostKeyPolicy seam (OQ-05, policy-blind)"
status: completed status: completed
depends_on: [w1-manifest] depends_on: [w1-manifest]
scope: narrow scope: narrow
@@ -12,27 +12,28 @@ level: implementation
## Description ## Description
Land the client-side host-key policy seam (`auth.md` §Host-key policy, Land the client-side host-key policy seam (`auth.md` §Host-key policy,
OQ-05 resolution) — the trust-decision contract the dial surface calls: OQ-05 resolution), the trust-decision contract the dial surface
calls:
- **The `HostKeyPolicy` trait** — the crate surfaces the presented host - **The `HostKeyPolicy` trait**: the crate surfaces the presented host
key (host, port, fingerprint) to an assembly-provided policy that key (host, port, fingerprint) to an assembly-provided policy that
decides accept/reject. The two OQ-05 cases are the *implementor's*: decides accept/reject. The two OQ-05 cases are the *implementor's*:
case-1 pins (known beforehand — hard-decline on mismatch) and case-2 case-1 pins (known beforehand, hard-decline on mismatch) and case-2
policies (unknown beforehand — prompt-and-record, learn-and-record, policies (unknown beforehand: prompt-and-record, learn-and-record,
provider-metadata match, accept-and-continue for ephemeral fleets). provider-metadata match, accept-and-continue for ephemeral fleets).
The crate is **policy-blind**: it never decides trust, never The crate is **policy-blind**: it never decides trust, never
hard-codes a trust model. hard-codes a trust model.
- **The crate default is fail-closed**: reject unknown (russh's own - **The crate default is fail-closed**: reject unknown (russh's own
reject-all default, kept as the built-in default policy). **No reject-all default, kept as the built-in default policy). **No
accept-all built-in crate option** — a permissive case-2 policy is accept-all built-in crate option**: a permissive case-2 policy is
the assembly's to write. the assembly's to write.
- The trait receives the fingerprint string (the wave-1 fingerprint - The trait receives the fingerprint string (the wave-1 fingerprint
computation is the caller's input path, wave-4 dial); the exact computation is the caller's input path, wave-4 dial); the exact
decision type (bool vs a richer verdict carrying a reason) is the decision type (bool vs a richer verdict carrying a reason) is the
task's to pin against the russh `check_server_key` hook shape at task's to pin against the russh `check_server_key` hook shape at
this pin — flagged for the wave-1 gate. this pin (flagged for the wave-1 gate).
- The russh client-handler consumption (`check_server_key` wiring) is - The russh client-handler consumption (`check_server_key` wiring) is
wave-4 dial-surface material — this task lands the seam and its wave-4 dial-surface material; this task lands the seam and its
default only. default only.
## Acceptance Criteria ## Acceptance Criteria
@@ -52,7 +53,7 @@ OQ-05 resolution) — the trust-decision contract the dial surface calls:
- docs/architecture/auth.md §Host-key policy (OQ-05 resolution) - docs/architecture/auth.md §Host-key policy (OQ-05 resolution)
- docs/architecture/client.md §The dial surface, §What the crate does - docs/architecture/client.md §The dial surface, §What the crate does
not include (trust decisions) not include (trust decisions)
- docs/architecture/decisions/005-at-rest-key-custody.md (decision 6 — - docs/architecture/decisions/005-at-rest-key-custody.md (decision 6:
the client host-*pin* is public config, never vault material) the client host-*pin* is public config, never vault material)
- docs/research/auth-bridge.md §Host keys and `check_server_key` - docs/research/auth-bridge.md §Host keys and `check_server_key`
@@ -62,11 +63,11 @@ OQ-05 resolution) — the trust-decision contract the dial surface calls:
> `RejectAll`) + lib.rs re-exports. Design decisions pinned against > `RejectAll`) + lib.rs re-exports. Design decisions pinned against
> the russh 0.65 hook shape (`Handler::check_server_key -> > the russh 0.65 hook shape (`Handler::check_server_key ->
> Result<bool, Self::Error>`, async via `impl Future`, default > Result<bool, Self::Error>`, async via `impl Future`, default
> `Ok(false)` — verified in the russh 0.65.0 source): > `Ok(false)`, verified in the russh 0.65.0 source):
> >
> - **The verdict type is `bool`** (the flagged pin): the hook's bool > - **The verdict type is `bool`** (the flagged pin): the hook's bool
> is the trust decision and its `Err` channel is the handler's > is the trust decision and its `Err` channel is the handler's
> wire/IO-failure territory — a trust decline has no typed reason > wire/IO-failure territory; a trust decline has no typed reason
> channel on this surface. A decline is a disposition, not an > channel on this surface. A decline is a disposition, not an
> error (`src/error.rs` contract); decline reasons are the > error (`src/error.rs` contract); decline reasons are the
> policy's own record (case-2 policies log their decisions), not a > policy's own record (case-2 policies log their decisions), not a
@@ -75,14 +76,14 @@ OQ-05 resolution) — the trust-decision contract the dial surface calls:
> per auth.md). > per auth.md).
> - **The trait is async and dyn-compatible**: `#[async_trait]`, > - **The trait is async and dyn-compatible**: `#[async_trait]`,
> `decide(&self, &PresentedHostKey) -> bool`, `Send + Sync + > `decide(&self, &PresentedHostKey) -> bool`, `Send + Sync +
> 'static` — the family injected-handle pattern (`Arc<dyn > 'static`, the family injected-handle pattern (`Arc<dyn
> HostKeyPolicy>`, the alkcall `IdentityProvider`/`OperationEnv` > HostKeyPolicy>`, the alkcall `IdentityProvider`/`OperationEnv`
> precedent), which w1-config's dial-config handle and > precedent), which w1-config's dial-config handle and
> w1-test-doubles' inspectable double both require. State-holding > w1-test-doubles' inspectable double both require. State-holding
> (learn-and-record) policies record by interior mutability > (learn-and-record) policies record by interior mutability
> behind the shared `&self` handle. > behind the shared `&self` handle.
> - **New dep `async-trait = "0.1"`** (MIT OR Apache-2.0; the only > - **New dep `async-trait = "0.1"`** (MIT OR Apache-2.0; the only
> way to get a dyn-compatible async trait at this toolchain — > way to get a dyn-compatible async trait at this toolchain, as
> native AFIT is not object-safe, and russh 0.65 removed its own > native AFIT is not object-safe, and russh 0.65 removed its own
> `async-trait` feature, per ADR-002's context). Precedented and > `async-trait` feature, per ADR-002's context). Precedented and
> deduped: alkcall already carries `async-trait 0.1`, so the tree > deduped: alkcall already carries `async-trait 0.1`, so the tree
+20 -19
View File
@@ -1,6 +1,6 @@
--- ---
id: w1-key-sources id: w1-key-sources
name: Wave 1 — vault-backed key sources (ADR-005 custody in code) name: "Wave 1: vault-backed key sources (ADR-005 custody in code)"
status: completed status: completed
depends_on: [w1-manifest, w1-error-model] depends_on: [w1-manifest, w1-error-model]
scope: moderate scope: moderate
@@ -11,33 +11,33 @@ level: implementation
## Description ## Description
Land the ADR-005 custody model in code — the key-source seam both Land the ADR-005 custody model in code: the key-source seam both
directions consume (client dial keys, server host keys): directions consume (client dial keys, server host keys):
- **The source seam** — the adapter takes *key sources*, not static - **The source seam**: the adapter takes *key sources*, not static
keys (the alkhttp ADR-014 pattern): a source trait the assembly keys (the alkhttp ADR-014 pattern): a source trait the assembly
implements over an injected alkvault handle, `EncryptedData` opaque implements over an injected alkvault handle, `EncryptedData` opaque
to the crate (ADR-005 decision 5 — the crate never interprets to the crate (ADR-005 decision 5: the crate never interprets
`key_version` or record internals). `key_version` or record internals).
- **At-use lifecycle** (decision 2) — `vault.decrypt(blob)` → - **At-use lifecycle** (decision 2): `vault.decrypt(blob)` →
**`Zeroizing<String>` wrap at the adapter seam** (decision 3 — the **`Zeroizing<String>` wrap at the adapter seam** (decision 3, the
known-gap contract; the vault-side improvement is upstream, not known-gap contract; the vault-side improvement is upstream, not
here) → one-time `decode_secret_key(plain, None)` → the decoded here) → one-time `decode_secret_key(plain, None)` → the decoded
`PrivateKey` lives for the dial's lifetime. The vault is needed only `PrivateKey` lives for the dial's lifetime. The vault is needed only
at dial time; locking mid-session keeps live sessions streaming. at dial time; locking mid-session keeps live sessions streaming.
- **Host-key load path** (decision 6) — decrypt-at-construction - **Host-key load path** (decision 6): decrypt-at-construction
returning the `PrivateKey` set the server `Config::keys` will returning the `PrivateKey` set the server `Config::keys` will
consume (the `Config` construction itself is wave 2; locked vault → consume (the `Config` construction itself is wave 2; locked vault →
the load fails → **no server is constructed**, POC-2 §5). the load fails → **no server is constructed**, POC-2 §5).
- **Failure mapping** — every vault failure mode maps to its distinct - **Failure mapping**: every vault failure mode maps to its distinct
`KeySourceError` variant (locked / absent / decryption / unknown `KeySourceError` variant (locked / absent / decryption / unknown
version / decode); never collapsed, never a fallback dial. version / decode); never collapsed, never a fallback dial.
- **Plaintext never persists** (decision 7) — no disk spill on this - **Plaintext never persists** (decision 7): no disk spill on this
path, ever; the `Zeroizing` wrap bounds the plaintext from the path, ever; the `Zeroizing` wrap bounds the plaintext from the
adapter seam onward. adapter seam onward.
Inner format is OpenSSH private-key PEM, unencrypted (decision 1) — Inner format is OpenSSH private-key PEM, unencrypted (decision 1);
the source contract assumes it; the decode call is the single the source contract assumes it, and the decode call is the single
conversion. conversion.
## Acceptance Criteria ## Acceptance Criteria
@@ -78,31 +78,31 @@ conversion.
> a *set* because `Config::keys` takes multiple). An assembly may > a *set* because `Config::keys` takes multiple). An assembly may
> implement either trait itself; the crate ships the ADR-005 > implement either trait itself; the crate ships the ADR-005
> implementations and the custody machinery. > implementations and the custody machinery.
> - **`VaultKeySource`** — decrypt-at-use over an injected clone of > - **`VaultKeySource`**: decrypt-at-use over an injected clone of
> the assembly's `VaultServiceHandle` + one injected > the assembly's `VaultServiceHandle` + one injected
> `Option<EncryptedData>` record (`None` → `Absent`, the POC-2 §1 > `Option<EncryptedData>` record (`None` → `Absent`, the POC-2 §1
> `KeySource { blob: Option<EncryptedData> }` shape). > `KeySource { blob: Option<EncryptedData> }` shape).
> **`VaultHostKeySource`** — same model over a record set (empty set > **`VaultHostKeySource`**: same model over a record set (empty set
> → `Absent`). Both are decrypt-at-use chains > → `Absent`). Both are decrypt-at-use chains
> (`decrypt_and_decode`, shared): `vault.decrypt(blob)` → > (`decrypt_and_decode`, shared): `vault.decrypt(blob)` →
> **`Zeroizing<String>` at the seam** → one-time > **`Zeroizing<String>` at the seam** → one-time
> `decode_secret_key(plain, None)`. Blobs are never interpreted in > `decode_secret_key(plain, None)`. Blobs are never interpreted in
> `src/` — no `key_version` reads, no record internals. > `src/`: no `key_version` reads, no record internals.
> - **Failure mapping** (`map_vault_error`): `VaultLocked` → > - **Failure mapping** (`map_vault_error`): `VaultLocked` →
> `Locked`; `InvalidPath` → `UnknownKeyVersion`; `Encryption` → > `Locked`; `InvalidPath` → `UnknownKeyVersion`; `Encryption` →
> `Decryption`. The vault's decrypt-path-unreachable variants > `Decryption`. The vault's decrypt-path-unreachable variants
> (`Derivation`/`Mnemonic`/`AlreadyUnlocked`/`UnsupportedKeyType`) > (`Derivation`/`Mnemonic`/`AlreadyUnlocked`/`UnsupportedKeyType`)
> land in the `Decryption` refuse-at-crypto class with honest > land in the `Decryption` refuse-at-crypto class with honest
> message text — the five-variant matrix stays closed, nothing > message text; the five-variant matrix stays closed, nothing
> collapses a real custody condition into success. > collapses a real custody condition into success.
> - **Tests** (12, all in-module with the real in-memory > - **Tests** (12, all in-module with the real in-memory
> alkvault handle — `VaultServiceHandle::new()` is the in-memory > alkvault handle: `VaultServiceHandle::new()` is the in-memory
> construction, so no local vault double was needed): > construction, so no local vault double was needed):
> round-trip (encrypt PEM → source → fingerprint-identical decodable > round-trip (encrypt PEM → source → fingerprint-identical decodable
> ed25519 key that signs), dyn-handle load, host-key set order, > ed25519 key that signs), dyn-handle load, host-key set order,
> load-works-after-lock (POC-2 §2 lifecycle), locked / absent / > load-works-after-lock (POC-2 §2 lifecycle), locked / absent /
> tampered-ciphertext / wrong-mnemonic → `Decryption` (distinct > tampered-ciphertext / wrong-mnemonic → `Decryption` (distinct
> from tampered only by message — both GCM-open) / version 0+1 → > from tampered only by message, both GCM-open) / version 0+1 →
> `UnknownKeyVersion` / non-key plaintext → `Decode`, locked > `UnknownKeyVersion` / non-key plaintext → `Decode`, locked
> host-bootstrap refusal, empty host set, v2→v3 rotation-preserves > host-bootstrap refusal, empty host set, v2→v3 rotation-preserves
> -identity through the source. Key fixtures: three committed > -identity through the source. Key fixtures: three committed
@@ -112,12 +112,13 @@ conversion.
> >
> Interpretation pinned for test-doubles: alkvault 0.1 *does* ship > Interpretation pinned for test-doubles: alkvault 0.1 *does* ship
> the in-memory construction (`VaultServiceHandle`), so the double > the in-memory construction (`VaultServiceHandle`), so the double
> wraps the real handle — no local decrypt/decrypt double. > wraps the real handle; no local decrypt/decrypt double.
## Summary ## Summary
> `src/key_source.rs` (lib re-exports `KeySource`, `HostKeySource`, > `src/key_source.rs` (lib re-exports `KeySource`, `HostKeySource`,
> `VaultKeySource`, `VaultHostKeySource`) — ADR-005 in code: the > `VaultKeySource`, `VaultHostKeySource`), and ADR-005 is in code:
> the
> injected-handle source seams both directions consume, the crate's > injected-handle source seams both directions consume, the crate's
> vault-backed implementations carrying decrypt-at-use (vault.decrypt > vault-backed implementations carrying decrypt-at-use (vault.decrypt
> → `Zeroizing<String>` → one-time `decode_secret_key`), opaque > → `Zeroizing<String>` → one-time `decode_secret_key`), opaque
+29 -28
View File
@@ -1,6 +1,6 @@
--- ---
id: w1-manifest id: w1-manifest
name: Wave 1 — manifest landing (russh pin + the feature graph) name: "Wave 1: manifest landing (russh pin + the feature graph)"
status: completed status: completed
depends_on: [] depends_on: []
scope: moderate scope: moderate
@@ -11,18 +11,19 @@ level: implementation
## Description ## Description
Land the implementation dependency set and the ADR-003 feature graph — Land the implementation dependency set and the ADR-003 feature graph:
the first commit of wave 1; everything else lands inside it. Per the first commit of wave 1, with everything else landing inside it.
Per
ADR-002 decision 6 ("manifest lands with the dependency") and ADR-002 decision 6 ("manifest lands with the dependency") and
`overview.md` §lifecycle note: `overview.md` §lifecycle note:
- **`russh = "0.65"`** (caret stops at the minor), `default-features = - **`russh = "0.65"`** (caret stops at the minor), `default-features =
false, features = ["aws-lc-rs"]` — the family crypto posture false, features = ["aws-lc-rs"]` per the family crypto posture
(decision 2), **no `flate2`** (decision 3), **no `rsa`** (decision 4: (decision 2), **no `flate2`** (decision 3), **no `rsa`** (decision 4:
ed25519-only; the tree must drop `rsa`/`pkcs1` RC deps entirely — ed25519-only; the tree must drop `rsa`/`pkcs1` RC deps entirely,
`ssh-key =0.7.0-rc.11` riding the russh keys path is the recorded with `ssh-key =0.7.0-rc.11` riding the russh keys path as the
exception). recorded exception).
- **`alkcall 0.10`** (identity/ACL types at the wire layer — - **`alkcall 0.10`** (identity/ACL types at the wire layer:
`IdentityProvider`, `Identity`, `AccessControl`), **`alkvault 0.1`** `IdentityProvider`, `Identity`, `AccessControl`), **`alkvault 0.1`**
(the ADR-005 custody model). Both pinned by version, never (the ADR-005 custody model). Both pinned by version, never
sibling-checkout paths. sibling-checkout paths.
@@ -31,21 +32,21 @@ ADR-002 decision 6 ("manifest lands with the dependency") and
- **`edition = "2024"`, `rust-version = "1.89"`** (supersedes the - **`edition = "2024"`, `rust-version = "1.89"`** (supersedes the
current 1.88/2021 manifest). current 1.88/2021 manifest).
- **The ADR-003 feature graph declared from the first commit**: - **The ADR-003 feature graph declared from the first commit**:
`session`, `git`, `tunnel`, `sftp`, `default = []` — flat, `session`, `git`, `tunnel`, `sftp`, `default = []`; the graph is
independent, no feature implies another. Features gate nothing yet flat, independent, and no feature implies another. Features gate
(the wave-2 decline core is their first consumer); the per-feature nothing yet (the wave-2 decline core is their first consumer); the
sibling deps (`alktty` under `session`, `alktunnels` under `tunnel`, per-feature sibling deps (`alktty` under `session`, `alktunnels`
`alkgit` under `git`) land with their first consuming wave, not as under `tunnel`, `alkgit` under `git`) land with their first
dead manifest entries — recorded interpretation, flagged for the consuming wave, not as dead manifest entries (recorded
wave-1 gate. interpretation, flagged for the wave-1 gate).
- Update the manifest comments (the current "deliberately empty until - Update the manifest comments (the current "deliberately empty until
implementation start" notes are superseded); keep comments to the implementation start" notes are superseded); keep comments to the
non-obvious-constraint class only. non-obvious-constraint class only.
Pre-flight: verify every pin exists on crates.io at the declared Pre-flight: verify every pin exists on crates.io at the declared
version (`russh 0.65`, `alkcall 0.10`, `alkvault 0.1`); if a version (`russh 0.65`, `alkcall 0.10`, `alkvault 0.1`); if a
crate/version is not published, stop and escalate — a blocking issue, crate/version is not published, stop and escalate, since a missing
not something to work around. pin is a blocking issue, not something to work around.
## Acceptance Criteria ## Acceptance Criteria
@@ -66,24 +67,24 @@ not something to work around.
- docs/architecture/decisions/002-russh-pin.md (decisions 1–6) - docs/architecture/decisions/002-russh-pin.md (decisions 1–6)
- docs/architecture/decisions/003-feature-graph.md (decision 1, §2) - docs/architecture/decisions/003-feature-graph.md (decision 1, §2)
- docs/architecture/overview.md §lifecycle note, §bounded-resources - docs/architecture/overview.md §lifecycle note, §bounded-resources
- docs/research/russh.md (the 2026-10-26 re-verification section — - docs/research/russh.md (the 2026-10-26 re-verification section:
feature names, tree inventory, MSRV) feature names, tree inventory, MSRV)
## Notes ## Notes
> Pre-flight executed: `cargo info` confirmed `russh 0.65.0`, > Pre-flight executed: `cargo info` confirmed `russh 0.65.0`,
> `alkcall 0.10.0`, and `alkvault 0.1.0` all published on crates.io — > `alkcall 0.10.0`, and `alkvault 0.1.0` all published on crates.io,
> no blocking issue. `alkcall` pinned as `"0.10"` (latest in-range is > so there is no blocking issue. `alkcall` pinned as `"0.10"` (latest
> 0.10.1; the caret stops at the minor, matching the ADR-002-russh > in-range is 0.10.1; the caret stops at the minor, matching the
> convention for family pins). Toolkit versions: `thiserror 2.0`, > ADR-002-russh convention for family pins). Toolkit versions:
> `tracing 0.1`, `parking_lot 0.12`, `zeroize 1.9`, > `thiserror 2.0`, `tracing 0.1`, `parking_lot 0.12`, `zeroize 1.9`,
> `tokio 1.54` (as already pinned). `alktty`/`alktunnels`/`alkgit` > `tokio 1.54` (as already pinned). `alktty`/`alktunnels`/`alkgit`
> sibling deps deliberately not added — they land with their first > sibling deps deliberately not added, as they land with their first
> consuming wave (recorded interpretation, flagged for the wave-1 > consuming wave (recorded interpretation, flagged for the wave-1
> gate as the task states; no dead manifest entries). Manifest > gate as the task states; no dead manifest entries). Manifest
> comments trimmed to the non-obvious-constraint class: the > comments trimmed to the non-obvious-constraint class: the
> russh/`default`-features posture (ADR-002 — must not be re-enabled > russh/`default`-features posture (ADR-002, which must not be
> by a future editor) and the fail-closed feature-graph note (ADR-003). > re-enabled by a future editor) and the fail-closed feature-graph note (ADR-003).
## Summary ## Summary
@@ -94,8 +95,8 @@ not something to work around.
> `edition = "2024"`, `rust-version = "1.89"`; ADR-003 feature graph > `edition = "2024"`, `rust-version = "1.89"`; ADR-003 feature graph
> (`session`/`git`/`tunnel`/`sftp`, `default = []`, flat and > (`session`/`git`/`tunnel`/`sftp`, `default = []`, flat and
> independent). Verified: `cargo tree` contains no `rsa`, `pkcs1`, or > independent). Verified: `cargo tree` contains no `rsa`, `pkcs1`, or
> `flate2` — only the recorded `ssh-key 0.7.0-rc.11` exception riding > `flate2` (only the recorded `ssh-key 0.7.0-rc.11` exception riding
> the russh keys path; `cargo test`, `cargo test --all-features`, > the russh keys path); `cargo test`, `cargo test --all-features`,
> `cargo clippy --all-targets -- -D warnings` (default and > `cargo clippy --all-targets -- -D warnings` (default and
> all-features), and `cargo fmt --check` all pass. `taskgraph > all-features), and `cargo fmt --check` all pass. `taskgraph
> validate` clean. > validate` clean.
+30 -26
View File
@@ -1,6 +1,6 @@
--- ---
id: w1-test-doubles id: w1-test-doubles
name: Wave 1 — test doubles (the in-memory seams) name: "Wave 1: test doubles (the in-memory seams)"
status: completed status: completed
depends_on: [w1-error-model, w1-key-sources, w1-host-key-policy] depends_on: [w1-error-model, w1-key-sources, w1-host-key-policy]
scope: narrow scope: narrow
@@ -15,21 +15,21 @@ Establish the test-double convention (adapted from the sibling crates)
and land the in-memory seams every later wave's contract tests run and land the in-memory seams every later wave's contract tests run
against: against:
- **In-memory `IdentityProvider`** — registered fingerprints → - **In-memory `IdentityProvider`**: registered fingerprints →
`Identity` resolutions over alkcall's trait; the double the auth `Identity` resolutions over alkcall's trait; the double the auth
bridge and dispatch tests resolve against. bridge and dispatch tests resolve against.
- **In-memory vault seam** — the encrypt/decrypt round-trip plus the - **In-memory vault seam**: the encrypt/decrypt round-trip plus the
failure postures (locked / absent / tampered) behind the same seam failure postures (locked / absent / tampered) behind the same seam
the key sources consume, so the custody tests run without a live the key sources consume, so the custody tests run without a live
vault service. If alkvault 0.1 ships an in-memory construction, wrap vault service. If alkvault 0.1 ships an in-memory construction, wrap
it; otherwise a local test double behind the source trait — the it; otherwise a local test double behind the source trait (the
task pins which, flagged for the wave-1 gate. task pins which, flagged for the wave-1 gate).
- **In-memory `HostKeyPolicy`** — a configurable record/verdict double - **In-memory `HostKeyPolicy`**: a configurable record/verdict double
(accept/reject per host, decision log inspectable) for the dial and (accept/reject per host, decision log inspectable) for the dial and
acceptor tests. acceptor tests.
- **ed25519 test-key fixtures** — throwaway test keys (generated at - **ed25519 test-key fixtures**: throwaway test keys (generated at
test time or committed static PEMs — the task pins which; committed test time or committed static PEMs, with the task pinning which;
keys are throwaway fixtures, not secrets) with recorded committed keys are throwaway fixtures, not secrets) with recorded
`ssh-keygen -lf` fingerprints, shared with the fingerprint parity `ssh-keygen -lf` fingerprints, shared with the fingerprint parity
tests and every later auth/mapping test. tests and every later auth/mapping test.
@@ -60,12 +60,12 @@ non-test code.
- docs/research/poc-2-vault-keys.md (the failure postures the vault - docs/research/poc-2-vault-keys.md (the failure postures the vault
double reproduces) double reproduces)
- The alkgit test-double convention (tasks/implementation/core/ - The alkgit test-double convention (tasks/implementation/core/
w1-test-doubles.md — reading reference, never copied code) w1-test-doubles.md; reading reference, never copied code)
## Notes ## Notes
- **Vault-double shape (recorded, flagged for the wave-1 gate):** alkvault - **Vault-double shape (recorded, flagged for the wave-1 gate):** alkvault
0.1 ships an in-memory construction — `VaultServiceHandle::new()` is an 0.1 ships an in-memory construction: `VaultServiceHandle::new()` is an
in-process vault that `unlock_new` arms in-memory. The double is a in-process vault that `unlock_new` arms in-memory. The double is a
**wrapper** (`TestVault` in `tests/common/mod.rs`) around that **wrapper** (`TestVault` in `tests/common/mod.rs`) around that
construction, not a local re-impl behind the source trait: the real construction, not a local re-impl behind the source trait: the real
@@ -74,36 +74,39 @@ non-test code.
`VaultHostKeySource` seams, and the POC-2 §3 postures (locked / `VaultHostKeySource` seams, and the POC-2 §3 postures (locked /
absent / tampered / wrong seed) ride the real seams. absent / tampered / wrong seed) ride the real seams.
- **Fixture decision (recorded):** committed static OpenSSH-PEM test - **Fixture decision (recorded):** committed static OpenSSH-PEM test
keys, minted by `ssh-keygen` at vector-generation time — not generated keys, minted by `ssh-keygen` at vector-generation time, not generated
at test time (determinism, no tooling dependency). Committed private at test time (determinism, no tooling dependency). Committed private
halves are throwaway fixtures by the task's own posture, documented as halves are throwaway fixtures by the task's own posture, documented as
such in `tests/vectors/fingerprints.txt` and in the common module docs. such in `tests/vectors/fingerprints.txt` and in the common module docs.
- **Location decision (recorded):** the doubles live only under - **Location decision (recorded):** the doubles live only under
`tests/common/mod.rs`, reached with `mod common;` — the alkgit `tests/common/mod.rs`, reached with `mod common;` (the alkgit
convention, adapted. Nothing doubles-related is reachable from convention, adapted). Nothing doubles-related is reachable from
`lib.rs`; no feature gates them. `src/`-unit tests that need the `lib.rs`; no feature gates them. `src/`-unit tests that need the
fixture material read the committed *files* via `include_str!` fixture material read the committed *files* via `include_str!`
(`src/key_source.rs` tests), never a copy — the single fixture set is (`src/key_source.rs` tests), never a copy; the single fixture set
is
shared by the src-side and tests/-side suites. dead_code is allowed shared by the src-side and tests/-side suites. dead_code is allowed
module-wide in `tests/common/` (each test binary compiles the whole module-wide in `tests/common/` (each test binary compiles the whole
shared module and never exercises all of it). shared module and never exercises all of it).
- **The fixture set was unified and re-minted (flagged for the gate):** - **The fixture set was unified and re-minted (flagged for the gate):**
the previous state had two overlapping throwaway sets — public-only the previous state had two overlapping throwaway sets (public-only
vectors under `tests/vectors/` (fingerprint parity) and inline vectors under `tests/vectors/`, used for fingerprint parity, and
private-PEM constants in `src/key_source.rs` — and the inline PEM inline private-PEM constants in `src/key_source.rs`), and the inline
PEM
constants, while decodable by russh, were **rejected by OpenSSH constants, while decodable by russh, were **rejected by OpenSSH
itself** (`ssh-keygen` → "error in libcrypto"): unusable as itself** (`ssh-keygen` → "error in libcrypto"): unusable as
cross-issuer fixtures. All four keys (`alpha`/`bravo`/`charlie`/ cross-issuer fixtures. All four keys (`alpha`/`bravo`/`charlie`/
`delta`) were freshly minted with OpenSSH_9.6p1's ssh-keygen, their `delta`) were freshly minted with OpenSSH_9.6p1's ssh-keygen, their
fingerprints recorded byte-verbatim in `tests/vectors/fingerprints.txt`, fingerprints recorded byte-verbatim in `tests/vectors/fingerprints.txt`,
and the two sets unified into one fixture table (`FIXTURE_KEYS`) — and the two sets unified into one fixture table, `FIXTURE_KEYS`
committed `.key` + `.pub` files, served to the fingerprint parity (committed `.key` + `.pub` files, served to the fingerprint parity
tests, the vault round-trip, and every later auth/mapping test. The tests, the vault round-trip, and every later auth/mapping test. The
old vector-only set was retired (its keys had no private halves). old vector-only set was retired (its keys had no private halves).
- The row/stamp discipline is established in `tests/common/mod.rs`'s - The row/stamp discipline is established in `tests/common/mod.rs`'s
module docs (adapted from the sibling crates): rows state properties module docs (adapted from the sibling crates): rows state properties
and carry greppable `Contract stamp:` items citing the pinning and carry greppable `Contract stamp:` items citing the pinning
ADR § — `grep -rn "Contract stamp:" tests/` is the inventory (11 ADR §, and `grep -rn "Contract stamp:" tests/` is the inventory
(11
stamps across the 11 landed rows). stamps across the 11 landed rows).
- No `src/` non-test changes: `src/key_source.rs` changed only inside - No `src/` non-test changes: `src/key_source.rs` changed only inside
its `#[cfg(test)]` module (fixture `include_str!`s + one parity row); its `#[cfg(test)]` module (fixture `include_str!`s + one parity row);
@@ -113,12 +116,13 @@ non-test code.
The wave-1 test-double convention landed: `tests/common/mod.rs` carries The wave-1 test-double convention landed: `tests/common/mod.rs` carries
`MemIdentityProvider` (registered fingerprints → alkcall `Identity` `MemIdentityProvider` (registered fingerprints → alkcall `Identity`
resolutions; token path resolves none — ADR-004's fingerprint-only resolutions; the token path resolves none (ADR-004's
disposition), `TestVault` (a wrapper over alkvault 0.1's shipped fingerprint-only disposition)), `TestVault` (a wrapper over alkvault 0.1's shipped
in-memory construction — the pinned resolution of the flagged in-memory construction, the pinned resolution of the flagged
vault-double question), `MemHostKeyPolicy` (exact `(host, fingerprint)` vault-double question), `MemHostKeyPolicy` (exact `(host, fingerprint)`
rules + explicit default verdict — fail-closed by default, permissive rules plus an explicit default verdict; fail-closed by default and
only by explicit choice — with a fully inspectable decision log), and permissive only by explicit choice, with a fully inspectable decision
log), and
the unified ed25519 fixture table (`FIXTURE_KEYS`: four committed the unified ed25519 fixture table (`FIXTURE_KEYS`: four committed
`ssh-keygen`-minted OpenSSH-PEM key pairs with recorded `ssh-keygen`-minted OpenSSH-PEM key pairs with recorded
`ssh-keygen -lf` fingerprints in `tests/vectors/fingerprints.txt`). The `ssh-keygen -lf` fingerprints in `tests/vectors/fingerprints.txt`). The
+35 -34
View File
@@ -1,6 +1,6 @@
--- ---
id: w2-auth-bridge id: w2-auth-bridge
name: Wave 2 — the auth bridge (dispositional handler surface) name: "Wave 2: the auth bridge (dispositional handler surface)"
status: completed status: completed
depends_on: [w2-server-mount, w1-fingerprint, w1-test-doubles] depends_on: [w2-server-mount, w1-fingerprint, w1-test-doubles]
scope: moderate scope: moderate
@@ -11,32 +11,32 @@ level: implementation
## Description ## Description
Fill the Handler skeleton's auth hooks with the ADR-004 dispositions — Fill the Handler skeleton's auth hooks with the ADR-004 dispositions:
the fingerprint → identity bridge, fail-closed on every path: the fingerprint → identity bridge, fail-closed on every path:
- **`auth_publickey`** (fires post-verification; the key arrives as - **`auth_publickey`** (fires post-verification; the key arrives as
`VerifiedPublicKey.public_key` with the signature `.algorithm` `VerifiedPublicKey.public_key` with the signature `.algorithm`
exposed): non-ed25519 algorithm → **explicit reject** (ADR-002 exposed): non-ed25519 algorithm → **explicit reject** (ADR-002
decision 4 — the explicit-over-inherited pattern, so the posture decision 4, the explicit-over-inherited pattern, so the posture
survives a pin bump); ed25519 → fingerprint via the crate's single survives a pin bump); ed25519 → fingerprint via the crate's single
fingerprint path (`src/fingerprint.rs`, the ADR-004 contract fingerprint path (`src/fingerprint.rs`, the ADR-004 contract
invariant) → `IdentityProvider::resolve_from_fingerprint` → invariant) → `IdentityProvider::resolve_from_fingerprint` →
`Some(identity)` → `Auth::Accept` with the resolved identity stored `Some(identity)` → `Auth::Accept` with the resolved identity stored
in the per-connection handler state (it rides the session for the in the per-connection handler state (it rides the session for the
wave-3 seam propagation — `auth_succeeded` is the ride-along hook); wave-3 seam propagation; `auth_succeeded` is the ride-along hook);
`None` → reject fail-closed. The resolved-identity-`None` path never `None` → reject fail-closed. The resolved-identity-`None` path never
creates a weakly authorized session. creates a weakly authorized session.
- **`auth_publickey_offered`** — accept-and-delegate (the russh - **`auth_publickey_offered`**: accept-and-delegate (the russh
default shape at this pin; keep it explicit so the counting stays default shape at this pin; keep it explicit so the counting stays
honest — the hook does not consume an attempt, POC-1 verified). honest (the hook does not consume an attempt, POC-1 verified).
- **Explicit declines** — `auth_none`, `auth_password`, - **Explicit declines**: `auth_none`, `auth_password`,
`auth_keyboard_interactive`, `auth_openssh_certificate` each get an `auth_keyboard_interactive`, `auth_openssh_certificate` each get an
explicit `Auth::reject()` implementation (family contract, not explicit `Auth::reject()` implementation (family contract, not
inherited defaults — a pin bump could silently change them). inherited defaults, which a pin bump could silently change).
- **The username carries no semantics** (OQ-03) — resolution is - **The username carries no semantics** (OQ-03): resolution is
fingerprint-only; the user string is never an identity input. fingerprint-only; the user string is never an identity input.
Auth declines are *dispositions* (`Auth::Reject`), not errors — the Auth declines are *dispositions* (`Auth::Reject`), not errors, so the
error model stays key-source-only. Tests run against the wave-1 error model stays key-source-only. Tests run against the wave-1
doubles (`MemIdentityProvider` + the ed25519 fixture keys) at the doubles (`MemIdentityProvider` + the ed25519 fixture keys) at the
handler level; the loopback harness proves the bridge end to end over handler level; the loopback harness proves the bridge end to end over
@@ -65,7 +65,7 @@ the wire in `w2-loopback-harness`.
dispositions, decisions 1–5) dispositions, decisions 1–5)
- docs/architecture/auth.md §Server direction (the bridge steps; the - docs/architecture/auth.md §Server direction (the bridge steps; the
0.65.0 `VerifiedPublicKey` shape) 0.65.0 `VerifiedPublicKey` shape)
- docs/architecture/decisions/002-russh-pin.md (decision 4 — - docs/architecture/decisions/002-russh-pin.md (decision 4;
ed25519-only, the explicit-decline rationale) ed25519-only, the explicit-decline rationale)
- docs/plans/implementation.md §Wave 2 (the auth-bridge bullet) - docs/plans/implementation.md §Wave 2 (the auth-bridge bullet)
- russh 0.65.0 in-source: `Auth`, `VerifiedPublicKey`, - russh 0.65.0 in-source: `Auth`, `VerifiedPublicKey`,
@@ -77,37 +77,37 @@ Implementation record (decisions made at this task's discretion):
- **The bridge core is structured out of the hook on purpose.** - **The bridge core is structured out of the hook on purpose.**
`VerifiedPublicKey` and `PublicKeyOffer` are `#[non_exhaustive]` `VerifiedPublicKey` and `PublicKeyOffer` are `#[non_exhaustive]`
with no external constructor — only russh builds them (and only with no external constructor; only russh builds them (and only
post-verification), so the *full* `auth_publickey`/`auth_publickey_offered` post-verification), so the *full* `auth_publickey`/`auth_publickey_offered`
hooks cannot be invoked directly from a test. The bridge is therefore hooks cannot be invoked directly from a test. The bridge is therefore
`SessionHandler::resolve_bridge(&public_key, &signature_algorithm)` — `SessionHandler::resolve_bridge(&public_key, &signature_algorithm)`,
the ed25519-only gate → the single fingerprint path → spanning the ed25519-only gate → the single fingerprint path →
`resolve_from_fingerprint` — a plain method the contract tests drive `resolve_from_fingerprint`; a plain method the contract tests drive
with ordinary key material, and the hook is one line over it (call, with ordinary key material, and the hook is one line over it (call,
store on `Some`, `Auth::reject()` on `None`). The non-constructible store on `Some`, `Auth::reject()` on `None`). The non-constructible
hooks are exercised as full hooks over the wire instead (see below). hooks are exercised as full hooks over the wire instead (see below).
- **Two test vehicles, both recorded.** Wire rows (the real - **Two test vehicles, both recorded.** Wire rows (the real
`SshAdapter` over a tokio-duplex pair + russh's own client — the `SshAdapter` over a tokio-duplex pair + russh's own client (the
`w2-server-mount` wire-smoke precedent) drive the full `w2-server-mount` wire-smoke precedent)) drive the full
`auth_publickey` hook end to end (accept, fail-closed reject, `auth_publickey` hook end to end (accept, fail-closed reject,
username independence, `auth_none`/`auth_password`/ username independence, `auth_none`/`auth_password`/
`auth_openssh_certificate` declines over the wire). Direct-hook rows `auth_openssh_certificate` declines over the wire). Direct-hook rows
against `SessionHandler` (over the wave-1 doubles) drive every against `SessionHandler` (over the wave-1 doubles) drive every
hook with constructible arguments plus `resolve_bridge`. The transport hook with constructible arguments plus `resolve_bridge`. The transport
choice and harness vocabulary remain `w2-loopback-harness`'s to pin — choice and harness vocabulary remain `w2-loopback-harness`'s to
these rows are its thin precursor, not a competing vehicle. pin; these rows are its thin precursor, not a competing vehicle.
- **`SessionHandler` visibility widened as `w2-server-mount`'s note - **`SessionHandler` visibility widened as `w2-server-mount`'s note
anticipated**: the struct is `pub` inside its still-private module, anticipated**: the struct is `pub` inside its still-private module,
re-exported `#[doc(hidden)]` from the crate root — constructible for re-exported `#[doc(hidden)]` from the crate root (constructible for
the contract tests, hidden from the docs; the documented public API the contract tests, hidden from the docs); the documented public API
stays exactly `SshAdapter` + the config/error/host/key-source types. stays exactly `SshAdapter` + the config/error/host/key-source types.
- **Non-ed25519 decline testing at this pin**: no wire row can carry a - **Non-ed25519 decline testing at this pin**: no wire row can carry a
non-ed25519 key — `rsa`/`pkcs1` are dropped from the dependency tree non-ed25519 key: `rsa`/`pkcs1` are dropped from the dependency tree
(ADR-002 decision 4's verification) and ssh-key ships without (ADR-002 decision 4's verification) and ssh-key ships without
ecdsa/dsa/rsa features, so no other key type even parses in-tree. ecdsa/dsa/rsa features, so no other key type even parses in-tree.
The gate is therefore tested on the bridge core with constructible The gate is therefore tested on the bridge core with constructible
stand-in signature algorithms (`SkEd25519`, stand-in signature algorithms (`SkEd25519`,
`SkEcdsaSha2NistP256`, `Other(name@domain)`) — a registered `SkEcdsaSha2NistP256`, `Other(name@domain)`); a registered
fingerprint under a non-ed25519 algorithm still declines, proving fingerprint under a non-ed25519 algorithm still declines, proving
the gate precedes resolution. The gate checks **both** algorithm the gate precedes resolution. The gate checks **both** algorithm
facts (the signature `.algorithm` from the hook and the key's facts (the signature `.algorithm` from the hook and the key's
@@ -116,11 +116,11 @@ Implementation record (decisions made at this task's discretion):
bump. bump.
- **`auth_publickey_offered`'s accept-and-delegate is explicit but not - **`auth_publickey_offered`'s accept-and-delegate is explicit but not
directly hook-testable at this pin** (non-exhaustive argument; the directly hook-testable at this pin** (non-exhaustive argument; the
russh client sends no `is_real=0` probe — OpenSSH-style clients do, russh client sends no `is_real=0` probe; OpenSSH-style clients do,
and POC-1 + russh's own `publickey_probes_do_not_burn_auth_attempts` and POC-1 + russh's own `publickey_probes_do_not_burn_auth_attempts`
test remain the counting-honesty evidence). The wire rows exercise test remain the counting-honesty evidence). The wire rows exercise
the signed-request path (`is_real=1`) that passes through it. the signed-request path (`is_real=1`) that passes through it.
- **`auth_succeeded` stays unimplemented (the russh default)** — the - **`auth_succeeded` stays unimplemented (the russh default)**: the
identity rides in per-connection handler state identity rides in per-connection handler state
(`SessionHandler::resolved_identity()`, the fail-closed `None` (`SessionHandler::resolved_identity()`, the fail-closed `None`
paths never store); wave 3's seam propagation consumes it there and paths never store); wave 3's seam propagation consumes it there and
@@ -129,7 +129,7 @@ Implementation record (decisions made at this task's discretion):
wave-3's assertion point. wave-3's assertion point.
- **Certificate fixture added** (`tests/vectors/`): `ca.key`/`ca.pub` - **Certificate fixture added** (`tests/vectors/`): `ca.key`/`ca.pub`
(throwaway CA, committed on the same fixture rationale as every (throwaway CA, committed on the same fixture rationale as every
other private half) and `alpha-cert.pub` — the alpha user other private half) and `alpha-cert.pub`, the alpha user
certificate signed by it (serial 1, key id `alkssh-fixture-alpha`, certificate signed by it (serial 1, key id `alkssh-fixture-alpha`,
principal `git`, validity 2026–2036). Both fingerprints recorded in principal `git`, validity 2026–2036). Both fingerprints recorded in
`fingerprints.txt`. `alpha-cert.pub` drives the `fingerprints.txt`. `alpha-cert.pub` drives the
@@ -139,7 +139,7 @@ Implementation record (decisions made at this task's discretion):
russh's pre-checks). russh's pre-checks).
- **`MemHostKeySource` double added to `tests/common`** (the wave-1 - **`MemHostKeySource` double added to `tests/common`** (the wave-1
doubles' module, still the only doubles location): an doubles' module, still the only doubles location): an
`absent()` shape (load refuses `Absent` — fail-closed if anything `absent()` shape (load refuses `Absent`, fail-closed if anything
ever loads it) and a `from_fixtures()` shape for the wire rows' real ever loads it) and a `from_fixtures()` shape for the wire rows' real
adapter construction (which wants a non-empty host-key set). adapter construction (which wants a non-empty host-key set).
@@ -148,22 +148,23 @@ Implementation record (decisions made at this task's discretion):
The ADR-004 dispositions are landed in `src/handler.rs`: The ADR-004 dispositions are landed in `src/handler.rs`:
`auth_publickey` is the fingerprint → `resolve_from_fingerprint` → `auth_publickey` is the fingerprint → `resolve_from_fingerprint` →
accept-with-identity / reject fail-closed bridge (non-ed25519 keys accept-with-identity / reject fail-closed bridge (non-ed25519 keys
declined *explicitly* by the bridge-core gate before any resolution — declined *explicitly* by the bridge-core gate before any resolution,
ADR-002 decision 4's explicit-over-inherited pattern), with the per ADR-002 decision 4's explicit-over-inherited pattern), with the
resolved identity stored in per-connection handler state resolved identity stored in per-connection handler state
(`SessionHandler::resolved_identity()`, read by wave 3); (`SessionHandler::resolved_identity()`, read by wave 3);
`auth_publickey_offered` is an explicit accept-and-delegate; `auth_publickey_offered` is an explicit accept-and-delegate;
`auth_none`/`auth_password`/`auth_keyboard_interactive`/ `auth_none`/`auth_password`/`auth_keyboard_interactive`/
`auth_openssh_certificate` are four explicit family-contract declines; `auth_openssh_certificate` are four explicit family-contract declines;
the username is structurally excluded from resolution (no username the username is structurally excluded from resolution (no username
parameter exists on the bridge core — OQ-03). Every decline is a parameter exists on the bridge core; OQ-03). Every decline is a
`Auth::Reject` disposition, never an error. `Auth::Reject` disposition, never an error.
Testing: `tests/auth_bridge.rs` — 7 wire rows over the real adapter Testing: `tests/auth_bridge.rs`, with 7 wire rows over the real
adapter
mount (registered-accept, unknown-reject with the `{publickey}` mount (registered-accept, unknown-reject with the `{publickey}`
advertisement surviving, four-username independence, revoke-between- advertisement surviving, four-username independence, revoke-between-
attempts, `auth_none`, `auth_password`, and the OpenSSH-certificate attempts, `auth_none`, `auth_password`, and the OpenSSH-certificate
decline — all via russh's own client over a duplex pair against the decline, all via russh's own client over a duplex pair against the
committed cert fixture) + 7 direct rows against `SessionHandler` and committed cert fixture) + 7 direct rows against `SessionHandler` and
`resolve_bridge` over the wave-1 doubles (all four non-publickey hooks `resolve_bridge` over the wave-1 doubles (all four non-publickey hooks
reject explicitly with no identity state left behind; registered / reject explicitly with no identity state left behind; registered /
@@ -182,5 +183,5 @@ precursor of the `w2-loopback-harness` vehicle (which pins the
transport choice); flagged for the wave-2 gate: the transport choice); flagged for the wave-2 gate: the
resolved-identity *landing* is code-verifiable only at wave 3 (the resolved-identity *landing* is code-verifiable only at wave 3 (the
hook's russh-owned argument shape is non-exhaustive and the handler hook's russh-owned argument shape is non-exhaustive and the handler
lives inside `run_stream`) — the store rides the same resolved value lives inside `run_stream`), so the store rides the same resolved
the wire rows exercise. value the wire rows exercise.
+25 -22
View File
@@ -1,6 +1,6 @@
--- ---
id: w2-bounded-resources id: w2-bounded-resources
name: Wave 2 — bounded resources (the in-crate channel cap at the handler surface) name: "Wave 2: bounded resources (the in-crate channel cap at the handler surface)"
status: completed status: completed
depends_on: [w2-spawn-return] depends_on: [w2-spawn-return]
scope: narrow scope: narrow
@@ -12,11 +12,11 @@ level: implementation
## Description ## Description
Enforce the in-crate bounded-resources posture at the handler surface Enforce the in-crate bounded-resources posture at the handler surface
(`overview.md` §Bounded-resources policy; AGENTS.md convention 9 — (`overview.md` §Bounded-resources policy; AGENTS.md convention 9:
SSH servers are internet-facing; unbounded loops and unbounded buffers SSH servers are internet-facing, and unbounded loops and unbounded
are bugs): buffers are bugs):
- **The in-crate concurrent-channel cap** — a count of active channels - **The in-crate concurrent-channel cap**, a count of active channels
enforced at every `channel_open_*` hook: opens beyond enforced at every `channel_open_*` hook: opens beyond
`Limits::max_concurrent_channels` are declined fail-closed (a clean `Limits::max_concurrent_channels` are declined fail-closed (a clean
refuse, never queued, never a half-open channel). The count refuse, never queued, never a half-open channel). The count
@@ -25,16 +25,16 @@ are bugs):
- **The russh knobs are already wired** (`w2-server-mount` mapped - **The russh knobs are already wired** (`w2-server-mount` mapped
`inactivity_timeout` always-`Some`, `max_auth_attempts`, `inactivity_timeout` always-`Some`, `max_auth_attempts`,
`window_size`, `maximum_packet_size`, `channel_buffer_size` from `window_size`, `maximum_packet_size`, `channel_buffer_size` from
`Limits`) — this task verifies them behaviorally where cheap `Limits`); this task verifies them behaviorally where cheap
(e.g., the auth-attempt ceiling disconnects an exceeding client (e.g., the auth-attempt ceiling disconnects an exceeding client
through the harness) and records anything not cheaply observable through the harness) and records anything not cheaply observable
as covered by the config-mapping tests. as covered by the config-mapping tests.
- **Per-length exec-parse limits stay reserved** for the wave-3 - **Per-length exec-parse limits stay reserved** for the wave-3
grammar's consumption (`Limits::max_exec_command_len` is carried, grammar's consumption (`Limits::max_exec_command_len` is carried,
not yet enforced — the grammar owns it). not yet enforced; the grammar owns it).
The per-identity policy layer (when the alkcall bridge carries it) is The per-identity policy layer (when the alkcall bridge carries it) is
additional and arrives with the bridge itself (wave 3's tunnel leg) — additional and arrives with the bridge itself (wave 3's tunnel leg),
not from this task. not from this task.
## Acceptance Criteria ## Acceptance Criteria
@@ -56,7 +56,7 @@ not from this task.
table) table)
- docs/architecture/mapping.md §Data-plane / limits - docs/architecture/mapping.md §Data-plane / limits
- docs/plans/implementation.md §Wave 2 (the bounded-resources bullet) - docs/plans/implementation.md §Wave 2 (the bounded-resources bullet)
- src/config.rs (`Limits` — the budget model and its documented - src/config.rs (`Limits`, the budget model and its documented
postures) postures)
## Notes ## Notes
@@ -65,16 +65,18 @@ not from this task.
`src/channel_cap.rs` (`ChannelCap`): the per-session active-channel `src/channel_cap.rs` (`ChannelCap`): the per-session active-channel
count with `admit` (check+increment at accept sites) / `has_room` count with `admit` (check+increment at accept sites) / `has_room`
(pure check) / `release` (saturating decrement). The only writer is (pure check) / `release` (saturating decrement). The only writer is
the connection's russh packet loop — every hook runs inline with the connection's russh packet loop, where every hook runs inline
with
exclusive `&mut self` (ADR-006 decision 4 puts serving paths on exclusive `&mut self` (ADR-006 decision 4 puts serving paths on
spawned tasks, but their closes cycle back through the packet loop spawned tasks, but their closes cycle back through the packet loop
as `CHANNEL_CLOSE`, firing the hook) — so the `parking_lot` as `CHANNEL_CLOSE`, firing the hook), so the `parking_lot`
convention's short-held *shared* state does not apply: no lock convention's short-held *shared* state does not apply: no lock
exists because no shared mutation does. Saturating `release` means exists because no shared mutation does. Saturating `release` means
a stray decrement cannot manufacture capacity. a stray decrement cannot manufacture capacity.
- **Gate ordering** (handler.rs): feature gate first, then the cap. - **Gate ordering** (handler.rs): feature gate first, then the cap.
Session-channel opens admit-and-count exactly at the accept point. Session-channel opens admit-and-count exactly at the accept point.
The tunnel rows check the cap *before* the seam stub — the stub The tunnel rows check the cap *before* the seam stub, because the
stub
declines without opening a channel, so a counted admission it never declines without opening a channel, so a counted admission it never
opens would leak against the cap forever (decline-first-alloc-never opens would leak against the cap forever (decline-first-alloc-never
applies to the budget, not just capability). Wave 3's applies to the budget, not just capability). Wave 3's
@@ -84,25 +86,26 @@ not from this task.
seam's `ConnectFailed` (pinned by a row, not by convention). x11 seam's `ConnectFailed` (pinned by a row, not by convention). x11
declines in every build and never consumes a count. declines in every build and never consumes a count.
- **Decline reason**: `dispatch::cap_exceeded_open_decline()` → - **Decline reason**: `dispatch::cap_exceeded_open_decline()` →
`AdministrativelyProhibited` — the cap is the assembly's configured `AdministrativelyProhibited`: the cap is the assembly's configured
policy ceiling; OpenSSH's own `MaxSessions` answers the same code, policy ceiling; OpenSSH's own `MaxSessions` answers the same code,
so vanilla clients see the vanilla-shaped policy refusal (POC-1 §5). so vanilla clients see the vanilla-shaped policy refusal (POC-1 §5).
- **Decrement invariant**: every fired `channel_close` corresponds to - **Decrement invariant**: every fired `channel_close` corresponds to
exactly one previously-admitted open — russh only routes exactly one previously-admitted open, since russh only routes
`CHANNEL_CLOSE` for channels it considers established (a declined `CHANNEL_CLOSE` for channels it considers established (a declined
open never established one) and consumes the close at most once open never established one) and consumes the close at most once
(the channel is removed from russh's map before the hook). (the channel is removed from russh's map before the hook).
- **Recovery observability (russh-pin fact, recorded in the test - **Recovery observability (russh-pin fact, recorded in the test
doc)**: a *client-initiated* channel close is not observable doc)**: a *client-initiated* channel close is not observable
client-side at this pin — russh removes a locally-closed channel client-side at this pin, because russh removes a locally-closed
channel
from its client map before the server's `CHANNEL_CLOSE` arrives from its client map before the server's `CHANNEL_CLOSE` arrives
back, so `Channel::wait()` never delivers the close (the harness's back, so `Channel::wait()` never delivers the close (the harness's
`await_close` observes only *server-initiated* closes, which is what `await_close` observes only *server-initiated* closes, which is what
the spawn-return/dispatch rows use). The recovery row therefore the spawn-return/dispatch rows use). The recovery row therefore
rests on wire ordering (deterministic): close and open ride the rests on wire ordering (deterministic): close and open ride the
client loop's single FIFO event queue, are written in that order, client loop's single FIFO event queue, are written in that order,
and the server's packet loop processes them in order — the counted and the server's packet loop processes them in order, so the
release precedes the recovery admission; had the release not counted release precedes the recovery admission; had the release not
happened, the recovery open declines and the row fails. happened, the recovery open declines and the row fails.
- **`max_auth_attempts` verified behaviorally**: ceiling `2` - **`max_auth_attempts` verified behaviorally**: ceiling `2`
(tunable) → two within-ceiling failures stay ordinary (tunable) → two within-ceiling failures stay ordinary
@@ -110,7 +113,7 @@ not from this task.
attempt past the ceiling is answered with the server's attempt past the ceiling is answered with the server's
`Disconnect::NoMoreAuthMethodsAvailable`, whose client-side shape is `Disconnect::NoMoreAuthMethodsAvailable`, whose client-side shape is
russh's synthesized `Failure` with an *empty* remaining-methods set russh's synthesized `Failure` with an *empty* remaining-methods set
(no reply came; the advertisement is gone) — the session ends (no reply came; the advertisement is gone), and the session ends
cleanly server-side (row: `tests/bounded_resources.rs` cleanly server-side (row: `tests/bounded_resources.rs`
`auth_attempt_ceiling_disconnects_an_exceeding_client`). `auth_attempt_ceiling_disconnects_an_exceeding_client`).
- **The size/window/clock knobs recorded as config-mapping-covered**: - **The size/window/clock knobs recorded as config-mapping-covered**:
@@ -124,21 +127,21 @@ not from this task.
the wire buys no new information (russh reads the `Config` fields it the wire buys no new information (russh reads the `Config` fields it
owns); not cheaply behavioral, so recorded, per the task's split. owns); not cheaply behavioral, so recorded, per the task's split.
- **`max_exec_command_len` stays carried, not enforced** (wave 3's - **`max_exec_command_len` stays carried, not enforced** (wave 3's
grammar owns it) — unchanged from `src/config.rs`/`src/dispatch.rs` grammar owns it), unchanged from `src/config.rs`/`src/dispatch.rs`
documentation; nothing here consumes it. documentation; nothing here consumes it.
- **Verification**: `cargo test` (default), and `--features session` - **Verification**: `cargo test` (default), and `--features session`
/ `git` / `tunnel` / `--all-features` (the feature-gated cap rows / `git` / `tunnel` / `--all-features` (the feature-gated cap rows
run in the builds whose opens can be accepted), `cargo clippy run in the builds whose opens can be accepted), `cargo clippy
--all-targets -- -D warnings` (+ `--all-features`), --all-targets -- -D warnings` (+ `--all-features`),
`cargo fmt --check`, `cargo doc --no-deps` — all green. `cargo fmt --check`, `cargo doc --no-deps`: all green.
## Summary ## Summary
Landed the in-crate concurrent-channel cap at the handler surface: Landed the in-crate concurrent-channel cap at the handler surface:
new private `channel_cap` module (count + admit/release semantics, new private `channel_cap` module (count + admit/release semantics,
unit-tested), wired into `SessionHandler`'s every open hook — the unit-tested), wired into `SessionHandler`'s every open hook: the
session accept path admits-and-counts at the accept, the tunnel rows session accept path admits-and-counts at the accept, the tunnel rows
cap-gate before the seam stub, x11 is unaffected — and decremented cap-gate before the seam stub, x11 is unaffected. Counts decrement
through the new `channel_close` hook (single release point, single through the new `channel_close` hook (single release point, single
writer). Cap-exceeded opens decline fail-closed with writer). Cap-exceeded opens decline fail-closed with
`AdministrativelyProhibited` (`dispatch::cap_exceeded_open_decline`, `AdministrativelyProhibited` (`dispatch::cap_exceeded_open_decline`,
@@ -1,6 +1,6 @@
--- ---
id: w2-dispatch-decline-core id: w2-dispatch-decline-core
name: Wave 2 — parse-only dispatch + the fail-closed decline core name: "Wave 2: parse-only dispatch + the fail-closed decline core"
status: completed status: completed
depends_on: [w2-loopback-harness] depends_on: [w2-loopback-harness]
scope: moderate scope: moderate
@@ -12,36 +12,40 @@ level: implementation
## Description ## Description
Land the dispatch skeleton and the fail-closed decline core (ADR-006 Land the dispatch skeleton and the fail-closed decline core (ADR-006
as amended, ADR-003) — the routing decisions and the declines; the as amended, ADR-003), which is to say the routing decisions and the
declines; the
family-seam serving paths are wave-3 material and arrive as clean family-seam serving paths are wave-3 material and arrive as clean
declining stubs this task shapes the routes for. declining stubs this task shapes the routes for.
- **Channel-open gates** (the `channel_open_*` hooks): `session` - **Channel-open gates** (the `channel_open_*` hooks): `session`
channels open only where a session-serving feature exists channels open only where a session-serving feature exists
(`session` ∨ `git` — the parse path lives inside session channels); (`session` ∨ `git`, since the parse path lives inside session
channels);
`direct-tcpip` / `forwarded-tcpip` / `direct-streamlocal` gate on `direct-tcpip` / `forwarded-tcpip` / `direct-streamlocal` gate on
the `tunnel` feature; **`x11` and `auth-agent` always decline** the `tunnel` feature; **`x11` and `auth-agent` always decline**
(never feature-gated — ADR-003 §5); every other/unimplemented (never feature-gated, per ADR-003 §5); every other/unimplemented
channel type declines. Unfeatured → decline at open with a clean channel type declines. Unfeatured → decline at open with a clean
wire reason — never a half-open channel. wire reason; never a half-open channel.
- **Parse-only session dispatch** (ADR-006 point 3 as amended): - **Parse-only session dispatch** (ADR-006 point 3 as amended):
`exec_request` parses first — a git-parseable command routes to the `exec_request` parses first: a git-parseable command routes to the
git row, anything else to the session pipe row; `pty_request` / git row, anything else to the session pipe row; `pty_request` /
`shell_request` route to the session rows. **The git-parseability `shell_request` route to the session rows. **The git-parseability
probe here is skeleton-level** (service prefix + path shape); the probe here is skeleton-level** (service prefix + path shape); the
full grammar (quoted/unquoted paths, service-only → full grammar (quoted/unquoted paths, service-only →
decline-with-reason, per-length limits) is wave-3's git task, which decline-with-reason, per-length limits) is wave-3's git task, which
replaces this probe. Parsing happens before dispatch and before any replaces this probe. Parsing happens before dispatch and before any
resource allocation — **decline first, alloc never** (a leaked resource allocation: **decline first, alloc never** (a leaked
channel alloc is a capability leak; the POC-3 stage-A rule). channel alloc is a capability leak; the POC-3 stage-A rule).
- **Request-level declines**: `subsystem_request` — the `sftp` name - **Request-level declines**: `subsystem_request`, where the `sftp`
name
declines-with-reason (the OQ-13 placeholder, folded here server declines-with-reason (the OQ-13 placeholder, folded here server
side); git service names decline-with-reason (a subsystem request side); git service names decline-with-reason (a subsystem request
carries only a name, never a repo id); everything else declines. carries only a name, never a repo id); everything else declines.
`env_request` declines unconditionally (OQ-09 — no allowlist ships). `env_request` declines unconditionally (OQ-09; no allowlist ships).
- **Routing stubs**: the git row and the session rows (pty/shell, - **Routing stubs**: the git row and the session rows (pty/shell,
pipe-exec) route to stubs that decline cleanly at the family-seam pipe-exec) route to stubs that decline cleanly at the family-seam
boundary — the seams land in wave 3; the stub shape is documented boundary, since the seams land in wave 3; the stub shape is
documented
as skeleton behavior so the routing decisions are contract-tested as skeleton behavior so the routing decisions are contract-tested
now, not after the mappings exist. now, not after the mappings exist.
- **Wire decline reasons are shaped here** (the wave-1 error-model - **Wire decline reasons are shaped here** (the wave-1 error-model
@@ -51,7 +55,7 @@ declining stubs this task shapes the routes for.
hangs). hangs).
- **Contract tests through the loopback harness, per feature build**: - **Contract tests through the loopback harness, per feature build**:
the skeleton rows of the decline matrix (`mapping.md` §Decline the skeleton rows of the decline matrix (`mapping.md` §Decline
matrix) — none-build declines everything; git-build routes matrix), where none-build declines everything; git-build routes
git-parseable exec (stub at the seam) and declines pty/shell and git-parseable exec (stub at the seam) and declines pty/shell and
plain exec; session-build declines git-parseable exec (the git plain exec; session-build declines git-parseable exec (the git
parse is its gate) and serves its rows only to the stub; tunnel parse is its gate) and serves its rows only to the stub; tunnel
@@ -82,7 +86,7 @@ declining stubs this task shapes the routes for.
## References ## References
- docs/architecture/decisions/006-mapping-bridge-and-dispatch.md - docs/architecture/decisions/006-mapping-bridge-and-dispatch.md
(points 2–3; the amendment — parse-only) (points 2–3; the amendment is parse-only)
- docs/architecture/decisions/003-feature-graph.md (the feature graph; - docs/architecture/decisions/003-feature-graph.md (the feature graph;
§5 never-features) §5 never-features)
- docs/architecture/mapping.md §Session-channel dispatch, §Request-level - docs/architecture/mapping.md §Session-channel dispatch, §Request-level
@@ -93,10 +97,10 @@ declining stubs this task shapes the routes for.
## Notes ## Notes
- `src/dispatch.rs` (new): the parse-only dispatch core — the - `src/dispatch.rs` (new): the parse-only dispatch core with the
`ExecRoute` decision (`Git` / `Pipe`), the skeleton exec-parse `ExecRoute` decision (`Git` / `Pipe`), the skeleton exec-parse
probe (`classify_exec`: exact `git-upload-pack`/`git-receive-pack` probe (`classify_exec`: exact `git-upload-pack`/`git-receive-pack`
service prefix + space + path-shaped argument — leading `'` or service prefix + space + path-shaped argument: leading `'` or
`/`; everything else → the pipe row), and the two wire-decline `/`; everything else → the pipe row), and the two wire-decline
reason shapers (`unfeatured_open_decline` → reason shapers (`unfeatured_open_decline` →
`AdministrativelyProhibited`, `tunnel_seam_stub_decline` → `AdministrativelyProhibited`, `tunnel_seam_stub_decline` →
@@ -111,24 +115,24 @@ declining stubs this task shapes the routes for.
parses first then routes on the feature graph; `pty`/`shell` route parses first then routes on the feature graph; `pty`/`shell` route
under `session`; `subsystem_request` declines every name (sftp = under `session`; `subsystem_request` declines every name (sftp =
the OQ-13 placeholder, `git-*` = no repository id in a name-only the OQ-13 placeholder, `git-*` = no repository id in a name-only
request — ADR-006 decision 2); `env_request` declines request per ADR-006 decision 2); `env_request` declines
unconditionally (OQ-09); `x11_request`/`agent_request` decline unconditionally (OQ-09); `x11_request`/`agent_request` decline
(ADR-003 §5); the four listen requests decline (the flow is wave (ADR-003 §5); the four listen requests decline (the flow is wave
3; never advertise a listen that cannot serve). Decline-first- 3; never advertise a listen that cannot serve). Decline-first-
alloc-never holds structurally: no hook allocates before its alloc-never holds structurally: no hook allocates before its
decline; a confirmed session open accepts and drops the channel decline; a confirmed session open accepts and drops the channel
handle (nothing is held this wave — the store-and-take handoff is handle (nothing is held this wave; the store-and-take handoff is
wave-3 mapping material, and the routing decisions around it are wave-3 mapping material, and the routing decisions around it are
already pinned by the seam stubs). already pinned by the seam stubs).
- **Wire decline reasons (the wave-1 error-model reservation, now - **Wire decline reasons (the wave-1 error-model reservation, now
spent):** open-level declines carry channel-open failure codes — spent):** open-level declines carry channel-open failure codes;
unfeatured and never-feature rows answer `AdministrativelyProhibited` unfeatured and never-feature rows answer `AdministrativelyProhibited`
(1) is the honest class: without the feature there is no capability (1) is the honest class: without the feature there is no capability
to distinguish; the tunnel seam stub answers `ConnectFailed` (2), to distinguish; the tunnel seam stub answers `ConnectFailed` (2),
the explicit reject shape ADR-006 decision 1 pins for failed fabric the explicit reject shape ADR-006 decision 1 pins for failed fabric
opens (accept only after the fabric open succeeds; the skeleton opens (accept only after the fabric open succeeds; the skeleton
never opens the fabric). Request-level declines carry never opens the fabric). Request-level declines carry
`channel_failure` — the SSH request vocabulary has no failure text, `channel_failure`: the SSH request vocabulary has no failure text,
so the "reason" is the routing decision, documented per build. so the "reason" is the routing decision, documented per build.
Within request declines, two shapes: a *capability* decline answers Within request declines, two shapes: a *capability* decline answers
failure and leaves the channel usable (POC-1 §5 posture, proven failure and leaves the channel usable (POC-1 §5 posture, proven
@@ -139,11 +143,11 @@ declining stubs this task shapes the routes for.
global requests auto-reply from the handler's `bool`; every other global requests auto-reply from the handler's `bool`; every other
request hook must call `session.channel_failure` itself. The bare request hook must call `session.channel_failure` itself. The bare
`auth-agent@openssh.com` (and `forwarded-streamlocal@openssh.com`) `auth-agent@openssh.com` (and `forwarded-streamlocal@openssh.com`)
channel *types* have no handler hook — russh rejects those opens channel *types* have no handler hook, so russh rejects those opens
inline (administratively prohibited); that is the decline the inline (administratively prohibited); that is the decline the
never-feature contract needs but it is an inherited posture flagged never-feature contract needs but it is an inherited posture flagged
in the handler docs (pin-bump change would appear as in the handler docs (pin-bump change would appear as
accepted-then-dropped — upstream note). `forwarded-tcpip` has the accepted-then-dropped, an upstream note). `forwarded-tcpip` has the
same gate/stub code shape as its tcp sibling but no client-side same gate/stub code shape as its tcp sibling but no client-side
open helper (the server opens it toward the client), so its wire open helper (the server opens it toward the client), so its wire
rows are wave-3 acceptor material. rows are wave-3 acceptor material.
@@ -170,23 +174,23 @@ declining stubs this task shapes the routes for.
Landed the parse-only dispatch + the fail-closed decline core Landed the parse-only dispatch + the fail-closed decline core
(`w2-dispatch-decline-core`) in three files plus the harness: (`w2-dispatch-decline-core`) in three files plus the harness:
- **`src/dispatch.rs`** — the `ExecRoute` decision, the skeleton - **`src/dispatch.rs`**: the `ExecRoute` decision, the skeleton
exec-parse probe (service prefix + path shape; wave-3 grammar exec-parse probe (service prefix + path shape; wave-3 grammar
replaces), the wire-reason shapers (`AdministrativelyProhibited` replaces), the wire-reason shapers (`AdministrativelyProhibited`
for unfeatured/never-feature opens, `ConnectFailed` for the tunnel for unfeatured/never-feature opens, `ConnectFailed` for the tunnel
seam stub), 7 probe unit rows. seam stub), 7 probe unit rows.
- **`src/handler.rs`** — the `channel_open_*` gates (session ∨ git; - **`src/handler.rs`**: the `channel_open_*` gates (session ∨ git;
tunnel; x11/auth-agent never), the parse-only `exec_request` tunnel; x11/auth-agent never), the parse-only `exec_request`
dispatch, the session-row routes, the request-level declines dispatch, the session-row routes, the request-level declines
(subsystem all-names with the sftp/git named classes, env, x11, (subsystem all-names with the sftp/git named classes, env, x11,
agent), the four listen-request declines, and the named seam agent), the four listen-request declines, and the named seam
stubs (`git_row_stub`, `pipe_row_stub`, `pty_row_stub`, stubs (`git_row_stub`, `pipe_row_stub`, `pty_row_stub`,
`shell_row_stub`, `tunnel_row_stub`) — the wave-3 replacement `shell_row_stub`, `tunnel_row_stub`), which are the wave-3
points, each documented with its family seam. Module docs now replacement points, each documented with its family seam. Module docs now
carry the decline-core contract (gates, decline classes, wire carry the decline-core contract (gates, decline classes, wire
reasons, decline-first-alloc-never, the parse-only posture, the reasons, decline-first-alloc-never, the parse-only posture, the
inherited-auth-agent-open upstream note). inherited-auth-agent-open upstream note).
- **`tests/dispatch_core.rs`** — the skeleton decline-matrix rows per - **`tests/dispatch_core.rs`**: the skeleton decline-matrix rows per
feature build, 26 rows total, each with a `Contract stamp:`: feature build, 26 rows total, each with a `Contract stamp:`:
shared rows in every build (x11 open, `tcpip-forward` decline, shared rows in every build (x11 open, `tcpip-forward` decline,
x11/agent requests where sessions open); none-build x11/agent requests where sessions open); none-build
@@ -197,9 +201,9 @@ Landed the parse-only dispatch + the fail-closed decline core
declines as gate, stub rows failure+close, capability declines, declines as gate, stub rows failure+close, capability declines,
tunnel unfeatured); tunnel-only (session opens decline, featured tunnel unfeatured); tunnel-only (session opens decline, featured
tunnel rows → `ConnectFailed` stubs); all-features (routes hold tunnel rows → `ConnectFailed` stubs); all-features (routes hold
with the full graph — git row, pipe row, pty/shell stubs, with the full graph: git row, pipe row, pty/shell stubs,
`ConnectFailed` stubs, capability declines). `ConnectFailed` stubs, capability declines).
- **`tests/common/harness.rs`** — the declined-vocabulary helpers - **`tests/common/harness.rs`**: the declined-vocabulary helpers
(tunnel/x11 opens, pty/env/x11/agent requests, listen requests), (tunnel/x11 opens, pty/env/x11/agent requests, listen requests),
module doc updated to record the discharge of the module doc updated to record the discharge of the
`w2-loopback-harness` note. `w2-loopback-harness` note.
+24 -24
View File
@@ -1,6 +1,6 @@
--- ---
id: w2-loopback-harness id: w2-loopback-harness
name: Wave 2 — the loopback harness (in-process client/server test pair) name: "Wave 2: the loopback harness (in-process client/server test pair)"
status: completed status: completed
depends_on: [w2-auth-bridge, w1-test-doubles] depends_on: [w2-auth-bridge, w1-test-doubles]
scope: moderate scope: moderate
@@ -12,31 +12,31 @@ level: implementation
## Description ## Description
Land the in-process client/server test pair every later contract test Land the in-process client/server test pair every later contract test
runs through — the decline matrix, every mapping's contract tests runs through: the decline matrix, every mapping's contract tests
(wave 3), and the client-direction smokes all drive the crate through (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 this harness. In-memory doubles from wave 1; real-tool smokes ride the
mapping waves (wave 3+) and the e2e wave (wave 5). mapping waves (wave 3+) and the e2e wave (wave 5).
- **Server side** — the `SshAdapter` from `w2-server-mount` over an - **Server side**: the `SshAdapter` from `w2-server-mount` over an
in-process transport. The transport choice (tokio duplex pair vs in-process transport. The transport choice (tokio duplex pair vs
127.0.0.1:0 TCP loopback) is pinned here — both work with 127.0.0.1:0 TCP loopback) is pinned here; both work with
`run_stream`/russh's client connect; decide on determinism and `run_stream`/russh's client connect; decide on determinism and
portability and record the choice. portability and record the choice.
- **Client side** — a russh client driven directly (the wave-4 dial - **Client side**: a russh client driven directly (the wave-4 dial
surface does not exist yet; the harness builds the russh client surface does not exist yet; the harness builds the russh client
config from the doubles): in-memory key from the ed25519 fixtures config from the doubles): in-memory key from the ed25519 fixtures
(`FIXTURE_KEYS`), the `MemHostKeyPolicy` double with the permissive (`FIXTURE_KEYS`), the `MemHostKeyPolicy` double with the permissive
test posture (`with_default(true)` — the accept-all posture exists test posture (`with_default(true)`; the accept-all posture exists
only as a test choice, never a crate built-in), the keepalive only as a test choice, never a crate built-in), the keepalive
posture from `Limits`. posture from `Limits`.
- **Harness helpers** — the vocabulary the contract tests speak: - **Harness helpers** (the vocabulary the contract tests speak):
authenticate with a fixture key (and observe auth failure shapes), authenticate with a fixture key (and observe auth failure shapes),
open a channel type (and observe the open-failure reason), send a open a channel type (and observe the open-failure reason), send a
session request (and observe success/failure), await exit-status / session request (and observe success/failure), await exit-status /
close. Helpers stay thin — the russh client API is the vocabulary; close. Helpers stay thin: the russh client API is the vocabulary;
the harness removes boilerplate, not semantics. the harness removes boilerplate, not semantics.
- **Convention** — lives with the wave-1 doubles under `tests/common/` - **Convention**: lives with the wave-1 doubles under `tests/common/`
(or a sibling harness module — same convention, decide and record); (or a sibling harness module, same convention, decide and record);
documented as *the* contract-test vehicle for the crate. documented as *the* contract-test vehicle for the crate.
The harness's first consumer is its own acceptance test: a full The harness's first consumer is its own acceptance test: a full
@@ -74,23 +74,23 @@ the wave-1 doubles work live through the wave-2 stack.
docs):** tokio duplex pair (64 KiB/direction) + russh's docs):** tokio duplex pair (64 KiB/direction) + russh's
`connect_stream`. russh's real codec/kex/transport run over the pair `connect_stream`. russh's real codec/kex/transport run over the pair
verbatim; the one gap versus real sockets (no OS-socket semantics) is verbatim; the one gap versus real sockets (no OS-socket semantics) is
covered more cheaply by the real-tool smokes of wave 3+ — TCP covered more cheaply by the real-tool smokes of wave 3+, since TCP
loopback's listener lifecycle and timing flakiness buy nothing the loopback's listener lifecycle and timing flakiness buy nothing the
harness needs. Deterministic, no port, no collision. harness needs. Deterministic, no port, no collision.
- **Placement decision (recorded, same docs):** `tests/common/harness.rs`, - **Placement decision (recorded, same docs):** `tests/common/harness.rs`,
a sibling module of the wave-1 doubles reached with the same a sibling module of the wave-1 doubles reached with the same
`mod common;` convention — test surface, never a public API export. `mod common;` convention; test surface, never a public API export.
Unlike the doubles it is a *vehicle* convention: later waves call Unlike the doubles it is a *vehicle* convention: later waves call
`common::harness::drive` rather than re-deriving a mount/connect pair. `common::harness::drive` rather than re-deriving a mount/connect pair.
- **russh 0.65 behavior surfaced by the harness (a dispatch-core - **russh 0.65 behavior surfaced by the harness (a dispatch-core
input):** each un-implemented server `channel_open_*` hook drops its input):** each un-implemented server `channel_open_*` hook drops its
`ChannelOpenHandle` → the client observes `ChannelOpenHandle` → the client observes
`ChannelOpenFailure::AdministrativelyProhibited` — the inherited `ChannelOpenFailure::AdministrativelyProhibited`, the inherited
fail-closed decline on the current skeleton stack. The harness's fail-closed decline on the current skeleton stack. The harness's
authenticated-session row matches the reason generically authenticated-session row matches the reason generically
(`Error::ChannelOpenFailure(_)`), so (`Error::ChannelOpenFailure(_)`), so
`w2-dispatch-decline-core`'s reason rows cannot break the vehicle. `w2-dispatch-decline-core`'s reason rows cannot break the vehicle.
- **Scope posture:** the harness is a test-vehicle task — no `src/` - **Scope posture:** the harness is a test-vehicle task, so no `src/`
change. A russh-server behavior note rides the row docs only. change. A russh-server behavior note rides the row docs only.
## Summary ## Summary
@@ -105,22 +105,22 @@ no-port portability) and **sibling-of-the-doubles placement** under
Contents: Contents:
- **`HarnessClientHandler`** — the russh client handler bridging the - **`HarnessClientHandler`**: the russh client handler bridging the
`MemHostKeyPolicy` double into `check_server_key`, computing the `MemHostKeyPolicy` double into `check_server_key`, computing the
decision input ([`PresentedHostKey`]) exactly as the wave-4 dial decision input ([`PresentedHostKey`]) exactly as the wave-4 dial
wiring will: endpoints labeled `LOOPBACK_HOST` / `LOOPBACK_PORT` wiring will: endpoints labeled `LOOPBACK_HOST` / `LOOPBACK_PORT`
(the no-port marker `0`), the fingerprint on the crate's single (the no-port marker `0`), the fingerprint on the crate's single
fingerprint path. fingerprint path.
- **`client_config`** — the keepalive/wall-clock posture from `Limits` - **`client_config`**: the keepalive/wall-clock posture from `Limits`
(interval/max verbatim, inactivity always set). (interval/max verbatim, inactivity always set).
- **Server-side assemblers** — `serve_config` / `adapter` from the - **Server-side assemblers**: `serve_config` / `adapter` from the
wave-1 doubles. wave-1 doubles.
- **`drive` → `LoopbackSession`** — the mount-spawn + connect pair, - **`drive` → `LoopbackSession`**: the mount-spawn + connect pair,
with clean-disconnect `unmount`. with clean-disconnect `unmount`.
- **Auth helpers** — `authenticate_publickey` (fixture plumbing - **Auth helpers**: `authenticate_publickey` (fixture plumbing
removed, `AuthResult` verbatim) and `remaining_methods` (the removed, `AuthResult` verbatim) and `remaining_methods` (the
advertisement half of the failure shape). advertisement half of the failure shape).
- **Channel helpers** — `open_session_channel` (open-failure reason - **Channel helpers**: `open_session_channel` (open-failure reason
observed in the `Err`), `request_exec` / `request_shell` / observed in the `Err`), `request_exec` / `request_shell` /
`request_subsystem`, and the message observations: `next_message`, `request_subsystem`, and the message observations: `next_message`,
`await_exit_status`, `await_close`. `await_exit_status`, `await_close`.
@@ -137,10 +137,10 @@ and the `Limits` → client-config posture row.
Live-exercised doubles: `MemIdentityProvider`, `MemHostKeySource`, Live-exercised doubles: `MemIdentityProvider`, `MemHostKeySource`,
`MemHostKeyPolicy` (both verdicts). `TestVault` stays on the custody `MemHostKeyPolicy` (both verdicts). `TestVault` stays on the custody
rows (fixture keys are unencrypted by contract). The session-request rows (fixture keys are unencrypted by contract). The session-request
and exit-status/close helpers are exercised by construction this wave and exit-status/close helpers are exercised by construction this
— channel opens don't confirm on the decline-only skeleton; their live wave, because channel opens don't confirm on the decline-only
rows arrive with `w2-dispatch-decline-core`. skeleton; their live rows arrive with `w2-dispatch-decline-core`.
Verification: `cargo test` (81 passed across 6 binaries), Verification: `cargo test` (81 passed across 6 binaries),
`cargo clippy --all-targets -- -D warnings`, `cargo fmt --check` — all `cargo clippy --all-targets -- -D warnings`, `cargo fmt --check`; all
green. green.
+30 -26
View File
@@ -1,6 +1,6 @@
--- ---
id: w2-server-mount id: w2-server-mount
name: Wave 2 — SshAdapter::handle mount + russh Config construction name: "Wave 2: SshAdapter::handle mount + russh Config construction"
status: completed status: completed
depends_on: [review-wave-1, w1-config, w1-key-sources, w1-error-model] depends_on: [review-wave-1, w1-config, w1-key-sources, w1-error-model]
scope: moderate scope: moderate
@@ -11,49 +11,51 @@ level: implementation
## Description ## Description
Land the door frame: the `SshAdapter` type — the ADR-001 mount point — Land the door frame: the `SshAdapter` type, the ADR-001 mount point,
and the russh server `Config` construction from the wave-1 and the russh server `Config` construction from the wave-1
`ServeConfig`. This is the substrate every mapping composes into; the `ServeConfig`. This is the substrate every mapping composes into; the
auth bridge and dispatch fill the Handler this task skeletons. auth bridge and dispatch fill the Handler this task skeletons.
- **`SshAdapter`** — holds the `ServeConfig`; construction - **`SshAdapter`**: holds the `ServeConfig`; construction
(`SshAdapter::new`) loads the host keys decrypt-at-construction via (`SshAdapter::new`) loads the host keys decrypt-at-construction via
the injected `HostKeySource` (ADR-005 decision 6): a locked / the injected `HostKeySource` (ADR-005 decision 6): a locked /
absent / decryption-failed / unknown-version load means **no server absent / decryption-failed / unknown-version load means **no server
is constructed** — the error surfaces as a distinct adapter is constructed**, and the error surfaces as a distinct adapter
construction error wrapping the `KeySourceError` variant (never a construction error wrapping the `KeySourceError` variant (never a
half-configured adapter, never a keyless server that fails later). half-configured adapter, never a keyless server that fails later).
- **russh `Config` construction** (ADR-002 surface notes) — built from - **russh `Config` construction** (ADR-002 surface notes), built from
the serve-config: `methods` = `{publickey}` **exactly** the serve-config: `methods` = `{publickey}` **exactly**
(`MethodSet` from `[MethodKind::Publickey]` — honest advertisement, (`MethodSet` from `[MethodKind::Publickey]`: honest advertisement,
ADR-004 decision 4); `keys` from the loaded host-key set; ADR-004 decision 4); `keys` from the loaded host-key set;
`window_size` / `maximum_packet_size` / `channel_buffer_size` / `window_size` / `maximum_packet_size` / `channel_buffer_size` /
`max_auth_attempts` mapped verbatim from `Limits`; `max_auth_attempts` mapped verbatim from `Limits`;
`inactivity_timeout` mapped **always-`Some`** (the bounded-resources `inactivity_timeout` mapped **always-`Some`** (the bounded-resources
posture — the crate never constructs a session with no wall clock); posture; the crate never constructs a session with no wall clock);
russh's rekey `Limits` stay russh-enforced at their RFC-4253 russh's rekey `Limits` stay russh-enforced at their RFC-4253
defaults (recorded in the wave-1 config notes — keep that defaults (recorded in the wave-1 config notes; keep that
recording). `server_id` stays russh's default posture unless the recording). `server_id` stays russh's default posture unless the
task finds a family reason otherwise (decide and record). task finds a family reason otherwise (decide and record).
- **The mount** — `SshAdapter::handle(stream)`: a generic stream in, - **The mount**: `SshAdapter::handle(stream)` with a generic stream
in,
the full russh server stack per stream via `russh::server::run_stream` the full russh server stack per stream via `russh::server::run_stream`
(edge termination only — no ALPN code, no relay leg; the `alk/ssh` (edge termination only, with no ALPN code and no relay leg; the
pin is a ledger entry, ADR-001 — nothing here speaks it). The exact `alk/ssh` pin is a ledger entry, ADR-001, and nothing here speaks
it). The exact
return shape (awaited `RunningSession`, an internal spawn + join return shape (awaited `RunningSession`, an internal spawn + join
handle, …) is decided here against the spawn-and-return posture handle, …) is decided here against the spawn-and-return posture
(ADR-006 decision 4) and the assembly's mounting needs — decide and (ADR-006 decision 4) and the assembly's mounting needs: decide and
record; it is semver-visible once consumers exist. record; it is semver-visible once consumers exist.
- **The Handler skeleton** — the per-connection `russh::server::Handler` - **The Handler skeleton**: the per-connection `russh::server::Handler`
impl with the adapter's `type Error` (`From<russh::Error>`, thiserror impl with the adapter's `type Error` (`From<russh::Error>` via
— the wave-2-shaped error surface the wave-1 error-model note thiserror, the wave-2-shaped error surface the wave-1 error-model
reserved); every hook left at its fail-closed russh default note reserved); every hook left at its fail-closed russh default
(decline/reject). Later wave-2 tasks fill the auth hooks (decline/reject). Later wave-2 tasks fill the auth hooks
(`w2-auth-bridge`) and the channel/request dispatch (`w2-auth-bridge`) and the channel/request dispatch
(`w2-dispatch-decline-core`). (`w2-dispatch-decline-core`).
No mapping seams, no feature-gated serving (wave 3); no client dial No mapping seams, no feature-gated serving (wave 3); no client dial
(wave 4). The feature gates stay untouched — nothing consumes them (wave 4). The feature gates stay untouched, since nothing consumes
yet (the dispatch task is their first `cfg` consumer). them yet (the dispatch task is their first `cfg` consumer).
## Acceptance Criteria ## Acceptance Criteria
@@ -80,7 +82,7 @@ yet (the dispatch task is their first `cfg` consumer).
contract: `SshAdapter::handle`, raw vanilla-SSH stream in) contract: `SshAdapter::handle`, raw vanilla-SSH stream in)
- docs/architecture/decisions/002-russh-pin.md (the Config surface - docs/architecture/decisions/002-russh-pin.md (the Config surface
notes; honest advertisement) notes; honest advertisement)
- docs/architecture/decisions/004-auth-dispositions.md (decision 4 — - docs/architecture/decisions/004-auth-dispositions.md (decision 4:
`methods = {publickey}`) `methods = {publickey}`)
- docs/architecture/decisions/005-at-rest-key-custody.md - docs/architecture/decisions/005-at-rest-key-custody.md
(decrypt-at-construction; locked → no server) (decrypt-at-construction; locked → no server)
@@ -94,10 +96,10 @@ yet (the dispatch task is their first `cfg` consumer).
Implementation record (decisions made at this task's discretion, the Implementation record (decisions made at this task's discretion, the
"decide and record" items): "decide and record" items):
- **The mount's return shape — `handle` runs to completion** - **The mount's return shape (`handle` runs to completion)**
(`Result<(), AdapterError>`): `handle` is an async per-stream call (`Result<(), AdapterError>`): `handle` is an async per-stream call
that awaits `run_stream`'s `RunningSession` through the session's that awaits `run_stream`'s `RunningSession` through the session's
end — the alkhttp door precedent (`HttpAdapter`/`ProtocolHandler:: end, matching the alkhttp door precedent (`HttpAdapter`/`ProtocolHandler::
handle` is spawned per connection by the assembly's accept loop; handle` is spawned per connection by the assembly's accept loop;
alkhttp/src/server/adapter.rs spawns it in tests the same way). alkhttp/src/server/adapter.rs spawns it in tests the same way).
ADR-006 decision 4 is satisfied inside russh itself: `run_stream` ADR-006 decision 4 is satisfied inside russh itself: `run_stream`
@@ -111,7 +113,7 @@ Implementation record (decisions made at this task's discretion, the
parameterizes); the second hides the failure boundary behind a parameterizes); the second hides the failure boundary behind a
detached task while still not matching the accept-loop shape any detached task while still not matching the accept-loop shape any
better. The chosen shape's signature is `Result<(), AdapterError>` better. The chosen shape's signature is `Result<(), AdapterError>`
— the smallest honest surface; it is semver-visible and recorded `, the smallest honest surface; it is semver-visible and recorded
here. here.
- **`server_id` stays russh's default** (`SSH-2.0-russh_0.65.0`, the - **`server_id` stays russh's default** (`SSH-2.0-russh_0.65.0`, the
`Config::default()` posture). No family reason found to change it: `Config::default()` posture). No family reason found to change it:
@@ -124,18 +126,19 @@ Implementation record (decisions made at this task's discretion, the
advertisement; ADR-002 decision 4's "an assembly registers ed25519 advertisement; ADR-002 decision 4's "an assembly registers ed25519
fingerprints only" governs what the assembly sources. Flagged for fingerprints only" governs what the assembly sources. Flagged for
the wave-2 gate: if host-key algorithm filtering ever becomes the wave-2 gate: if host-key algorithm filtering ever becomes
wire-contract territory (currently it is not — the key source wire-contract territory (currently it is not, as the key source
contract is silent), it would be an ADR amendment, not this task. contract is silent), it would be an ADR amendment, not this task.
- **Empty key set = `KeySourceError::Absent` even from a custom - **Empty key set = `KeySourceError::Absent` even from a custom
source**: `SshAdapter::new` additionally refuses an `Ok(vec![])` source**: `SshAdapter::new` additionally refuses an `Ok(vec![])`
load as `Construction(Absent)` — `Config::keys` empty would serve load as `Construction(Absent)`, since an empty `Config::keys` would
serve
no host key honestly, and the `key_source.rs` wave-1 contract no host key honestly, and the `key_source.rs` wave-1 contract
already documents the empty set as the absent shape. already documents the empty set as the absent shape.
- **Keepalive stays russh-default on the server direction** - **Keepalive stays russh-default on the server direction**
(`None` / 3): the `Limits` keepalive knobs are documented (`None` / 3): the `Limits` keepalive knobs are documented
client-direction posture (reverse-runner evidence); no server-side client-direction posture (reverse-runner evidence); no server-side
keepalive posture exists and none is invented here. keepalive posture exists and none is invented here.
- **`SessionHandler` is `pub(crate)`** — the skeleton's type never - **`SessionHandler` is `pub(crate)`**: the skeleton's type never
appears in a public signature (thanks to the run-to-completion appears in a public signature (thanks to the run-to-completion
return shape), so the public API this task exposes is exactly return shape), so the public API this task exposes is exactly
`SshAdapter` + `AdapterError`; `w2-auth-bridge` can widen visibility `SshAdapter` + `AdapterError`; `w2-auth-bridge` can widen visibility
@@ -166,7 +169,8 @@ stands entirely at russh's fail-closed defaults, with
`AdapterError` (`Construction(#[from] KeySourceError)` + `AdapterError` (`Construction(#[from] KeySourceError)` +
`Wire(#[from] russh::Error)`) as its `type Error`. `Wire(#[from] russh::Error)`) as its `type Error`.
Verification: 12 new tests — advertisement-exactly-publickey, knob Verification: 12 new tests covering advertisement-exactly-publickey,
knob
mapping (tuned + documented-default postures), decrypt-at-construction mapping (tuned + documented-default postures), decrypt-at-construction
key-set parity, construction failure matrix (locked / absent / empty / key-set parity, construction failure matrix (locked / absent / empty /
tampered / wrong-vault / unknown-version / decode, variant preserved), tampered / wrong-vault / unknown-version / decode, variant preserved),
+32 -29
View File
@@ -1,6 +1,6 @@
--- ---
id: w2-spawn-return id: w2-spawn-return
name: Wave 2 — spawn-and-return plumbing + exit/status reporting mechanics name: "Wave 2: spawn-and-return plumbing + exit/status reporting mechanics"
status: completed status: completed
depends_on: [w2-dispatch-decline-core] depends_on: [w2-dispatch-decline-core]
scope: narrow scope: narrow
@@ -13,22 +13,23 @@ level: implementation
Land the structural constraint in code (ADR-006 decision 4): russh at Land the structural constraint in code (ADR-006 decision 4): russh at
this pin runs server handler callbacks **inline inside the session's this pin runs server handler callbacks **inline inside the session's
packet loop** — a blocking handler suspends all packet routing for the packet loop**, so a blocking handler suspends all packet routing
connection and deadlocks against the peer's immediate follow-up bytes connection and deadlocks against the peer's immediate follow-up bytes
(POC-3 proved by construction with receive-pack). Therefore: (POC-3 proved by construction with receive-pack). Therefore:
- **Every serving path is `tokio::spawn`ed; handlers return fast.** - **Every serving path is `tokio::spawn`ed; handlers return fast.**
The dispatch's routing stubs become spawned serving paths (still The dispatch's routing stubs become spawned serving paths (still
declining stubs — the seams are wave 3), so the pattern is in place declining stubs, since the seams are wave 3), so the pattern is in
place
before any real mapping hangs on it. before any real mapping hangs on it.
- **Exit/status reporting mechanics** — reporting from spawned tasks - **Exit/status reporting mechanics**: reporting from spawned tasks
rides the cloned `Session::handle()`: `exit-status` sent **before** rides the cloned `Session::handle()`: `exit-status` sent **before**
channel close, from the spawned task. The *derivation contract's channel close, from the spawned task. The *derivation contract's
rules* (which code → which SSH exit shape) are wave-3 rules* (which code → which SSH exit shape) are wave-3
session-mapping material (`mapping.md` rules 1–6); this task lands session-mapping material (`mapping.md` rules 1–6); this task lands
the mechanics — the handle clone, the exit-status send, the the mechanics (the handle clone, the exit-status send, the
ordering-before-close — proven end to end. ordering-before-close), proven end to end.
- **The proof** — a stub serving path that spawns, completes - **The proof**: a stub serving path that spawns, completes
asynchronously (after the handler has returned), and reports asynchronously (after the handler has returned), and reports
`exit-status(N)` + close through the cloned handle; the loopback `exit-status(N)` + close through the cloned handle; the loopback
harness client observes the exit-status arriving after the handler harness client observes the exit-status arriving after the handler
@@ -37,7 +38,7 @@ connection and deadlocks against the peer's immediate follow-up bytes
constraint as a test, not a convention. constraint as a test, not a convention.
This is a load-bearing correctness constraint of the whole spine, not This is a load-bearing correctness constraint of the whole spine, not
an implementation choice — the wave-3 mappings inherit the pattern an implementation choice; the wave-3 mappings inherit the pattern
mechanically from the stubs this task leaves behind. mechanically from the stubs this task leaves behind.
## Acceptance Criteria ## Acceptance Criteria
@@ -57,7 +58,7 @@ mechanically from the stubs this task leaves behind.
## References ## References
- docs/architecture/decisions/006-mapping-bridge-and-dispatch.md - docs/architecture/decisions/006-mapping-bridge-and-dispatch.md
(decision 4 — the structural constraint) (decision 4, the structural constraint)
- docs/architecture/mapping.md §The structural constraint: - docs/architecture/mapping.md §The structural constraint:
spawn-and-return; §exit_status row (sent before close, from the spawn-and-return; §exit_status row (sent before close, from the
spawned task) spawned task)
@@ -68,68 +69,69 @@ mechanically from the stubs this task leaves behind.
## Notes ## Notes
- `src/serve.rs` (new): the spawn-and-return plumbing as *mechanics*, - `src/serve.rs` (new): the spawn-and-return plumbing as *mechanics*,
not convention — `spawn_serving_path(serving future)` (the one not convention: `spawn_serving_path(serving future)` (the one
choke point for the ADR-006 decision-4 spawn; nothing it takes is choke point for the ADR-006 decision-4 spawn; nothing it takes is
ever awaited inline) and `report_exit_status_then_close(&Handle, ever awaited inline) and `report_exit_status_then_close(&Handle,
channel, N)` (the cloned-handle report with the `mapping.md` channel, N)` (the cloned-handle report with the `mapping.md`
`exit_status` row's ordering baked in: exit-status **before** `exit_status` row's ordering baked in: exit-status **before**
close). Both re-exported `#[doc(hidden)]` from the crate root — the close). Both are re-exported `#[doc(hidden)]` from the crate root,
`SessionHandler` §Test-surface pattern — as the contract-test the `SessionHandler` §Test-surface pattern, as the contract-test
surface the mechanics rows compose over the real wire. A unit row surface the mechanics rows compose over the real wire. A unit row
(`spawn_returns_while_the_serving_path_is_still_pending` — (`spawn_returns_while_the_serving_path_is_still_pending`,
latch-gated future, spawn call returns first) pins spawn-then-return latch-gated future, spawn call returns first) pins spawn-then-return
directly. The derivation contract's *rules* (which exit code → which directly. The derivation contract's *rules* (which exit code → which
SSH exit shape) stay wave-3 `mapping.md` material; no rule logic SSH exit shape) stay wave-3 `mapping.md` material; no rule logic
landed here. landed here.
- `src/handler.rs`: the four session-row seam stubs (`git_row_stub`, - `src/handler.rs`: the four session-row seam stubs (`git_row_stub`,
`pipe_row_stub`, `pty_row_stub`, `shell_row_stub`) are now spawned `pipe_row_stub`, `pty_row_stub`, `shell_row_stub`) are now spawned
serving paths — `spawn_seam_stub` clones `session.handle()`, serving paths: `spawn_seam_stub` clones `session.handle()`,
`seam_stub_decline(handle, channel)` runs the failure-then-close `seam_stub_decline(handle, channel)` runs the failure-then-close
decline on the spawned task, and the hooks return without awaiting decline on the spawned task, and the hooks return without awaiting
anything. A compile-level fact coded around: the row stubs are anything. A compile-level fact coded around: the row stubs are
plain `fn`s (not `async`) — an `async fn` holding `&Session` across plain `fn`s (not `async`), because an `async fn` holding `&Session`
across
the spawn makes the handler future non-`Send` (russh requires `+ the spawn makes the handler future non-`Send` (russh requires `+
Send` on handler futures; `Session` is not `Sync`), so the clone is Send` on handler futures; `Session` is not `Sync`), so the clone is
taken synchronously and only the handle moves into the spawned taken synchronously and only the handle moves into the spawned
future. Wire shapes are unchanged (all `w2-dispatch-decline-core` future. Wire shapes are unchanged (all `w2-dispatch-decline-core`
rows pass unmodified with the spawned decline); the tunnel seam stub rows pass unmodified with the spawned decline); the tunnel seam stub
and every open-level/capability decline stay inline by class — they and every open-level/capability decline stay inline by class, as
answer the open request *before* any channel is owned, so nothing they answer the open request *before* any channel is owned, so nothing
spawns yet (documented in the new handler module-doc section §The spawns yet (documented in the new handler module-doc section §The
spawn-and-return structural constraint; wave 3's spawn-and-return structural constraint; wave 3's
accept-only-after-fabric-open flow is where the tunnel row gains accept-only-after-fabric-open flow is where the tunnel row gains
its spawn). its spawn).
- `tests/spawn_return.rs` (new): the end-to-end mechanics proof over - `tests/spawn_return.rs` (new): the end-to-end mechanics proof over
the real wire — the russh server stack (duplex transport, harness the real wire, using the russh server stack (duplex transport,
client/harness transport posture) mounted over a minimal proof harness client/harness transport posture) mounted over a minimal proof
handler that is *not* the crate's `SessionHandler` (the auth and handler that is *not* the crate's `SessionHandler` (the auth and
dispatch hooks have their own suites; the proof subject is the dispatch hooks have their own suites; the proof subject is the
mechanics) and that serves execs by composing the exported mechanics) and that serves execs by composing the exported
plumbing: `spawn_serving_path` of a release-gated serving future plumbing: `spawn_serving_path` of a release-gated serving future
reporting `exit-status(7)` + close through the cloned handle. Two reporting `exit-status(7)` + close through the cloned handle. Two
rows, each with a `Contract stamp:`: rows, each with a `Contract stamp:`:
- `handler_returns_fast_while_the_spawned_serving_path_is_pending` — - `handler_returns_fast_while_the_spawned_serving_path_is_pending`,
the structural constraint as a wire test: after the exec hook has the structural constraint as a wire test: after the exec hook has
provably returned (a bounded wait on the handler-side timestamp; provably returned (a bounded wait on the handler-side timestamp;
the client's exec returns when the request is on the wire, not the client's exec returns when the request is on the wire, not
when the hook ran) and with the serving path still gated, the when the hook ran) and with the serving path still gated, the
client's *follow-up traffic* (a second session channel + second client's *follow-up traffic* (a second session channel + second
exec) is routed and dispatched normally — POC-3's follow-up-bytes exec) is routed and dispatched normally, with POC-3's follow-up-bytes
deadlock shape inverted — and the still-gated no-report state is deadlock shape inverted, and the still-gated no-report state is
asserted before the release. asserted before the release.
- `spawned_stub_reports_exit_status_before_close_after_the_handler_returned` - `spawned_stub_reports_exit_status_before_close_after_the_handler_returned`
— the client observes `ExitStatus{7}` strictly before `Close` with the client observing `ExitStatus{7}` strictly before `Close`
(message-by-message, no draining that could hide ordering), and (message-by-message, no draining that could hide ordering), and
the recorded timestamps prove the exit-status left *after* the the recorded timestamps prove the exit-status left *after* the
handler had already returned (the literal criterion). handler had already returned (the literal criterion).
- **The proof-handler design note:** the gate is `tokio::sync::watch`- - **The proof-handler design note:** the gate is `tokio::sync::watch`-
based (no lost-wakeup race — a late subscriber sees the release, based (no lost-wakeup race; a late subscriber sees the release,
which a `Notify`-with-`notify_waiters` shape could miss against which a `Notify`-with-`notify_waiters` shape could miss against
multiple spawned paths); the handler-side timestamps ride multiple spawned paths); the handler-side timestamps ride
`parking_lot::Mutex`. `parking_lot::Mutex`.
- Feature independence: the mechanics are feature-independent (the - Feature independence: the mechanics are feature-independent (the
helpers exist in every build and the proof rows run in every build helpers exist in every build and the proof rows run in every build
they were verified under — default, `git`, `session`, `tunnel`, they were verified under (default, `git`, `session`, `tunnel`,
`sftp`, `--all-features`); no feature-gated code was added, so no `sftp`, `--all-features`); no feature-gated code was added, so no
new decline-path coverage obligations arise. new decline-path coverage obligations arise.
@@ -138,21 +140,22 @@ mechanically from the stubs this task leaves behind.
Landed the spawn-and-return plumbing + exit/status reporting Landed the spawn-and-return plumbing + exit/status reporting
mechanics (`w2-spawn-return`) in three files: mechanics (`w2-spawn-return`) in three files:
- **`src/serve.rs`** — `cargo test`-pinned mechanics: the - **`src/serve.rs`** with `cargo test`-pinned mechanics: the
`spawn_serving_path` choke point (the ADR-006 decision-4 rule, in `spawn_serving_path` choke point (the ADR-006 decision-4 rule, in
code, never awaited inline) and `report_exit_status_then_close` code, never awaited inline) and `report_exit_status_then_close`
(exit-status before close through the cloned `Session::handle()`), (exit-status before close through the cloned `Session::handle()`),
both `#[doc(hidden)]` contract-test re-exports; a unit row pins both `#[doc(hidden)]` contract-test re-exports; a unit row pins
spawn-returns-while-pending directly. spawn-returns-while-pending directly.
- **`src/handler.rs`** — the four session-row seam stubs became - **`src/handler.rs`**: the four session-row seam stubs became
spawned serving paths (handle clone taken synchronously — the spawned serving paths (handle clone taken synchronously, given the
non-`Send` shape that forcing `async fn` stubs would produce is non-`Send` shape that forcing `async fn` stubs would produce is
coded around and recorded); failure-then-close wire shapes unchanged coded around and recorded); failure-then-close wire shapes unchanged
(all dispatch-core rows pass unmodified); open-level declines and (all dispatch-core rows pass unmodified); open-level declines and
the tunnel seam stub stay inline by class, documented; a new the tunnel seam stub stay inline by class, documented; a new
module-doc section carries the constraint and the wave-3 module-doc section carries the constraint and the wave-3
inherit-the-pattern note. inherit-the-pattern note.
- **`tests/spawn_return.rs`** — the end-to-end mechanics proof: two - **`tests/spawn_return.rs`**: the end-to-end mechanics proof, with
two
`Contract stamp:` rows over the real wire (handler-returns-fast `Contract stamp:` rows over the real wire (handler-returns-fast
under follow-up traffic with the spawn still pending; exit-status under follow-up traffic with the spawn still pending; exit-status
before close, timestamps proving the report outlived the handler's before close, timestamps proving the report outlived the handler's
+58 -52
View File
@@ -1,6 +1,6 @@
--- ---
id: w3-decline-tracing id: w3-decline-tracing
name: Wave 3 — decline-path tracing (the wave-2 gate's booked finding) name: "Wave 3: decline-path tracing (the wave-2 gate's booked finding)"
status: completed status: completed
depends_on: [w3-seam-config] depends_on: [w3-seam-config]
scope: narrow scope: narrow
@@ -13,35 +13,35 @@ level: implementation
Land the wave-2 review gate's booked finding: observability on the Land the wave-2 review gate's booked finding: observability on the
decline paths. `tracing` rides the manifest per the ADR-002 toolkit but decline paths. `tracing` rides the manifest per the ADR-002 toolkit but
so far nothing in `src/` emits — a request-level decline's "reason" is so far nothing in `src/` emits, so a request-level decline's
today only the routing decision documented per build. The real serving "reason" is today only the routing decision documented per build. The real serving
paths land in this wave's mapping tasks, where serve/decline tracing paths land in this wave's mapping tasks, where serve/decline tracing
matters — so the event **vocabulary** lands first and the mappings matters; therefore the event **vocabulary** lands first and the
inherit it. mappings inherit it.
- **The family-convention check first** — before choosing the event - **The family-convention check first**: before choosing the event
shape, read the family's established tracing posture (alktty / shape, read the family's established tracing posture (alktty /
alkgit / alktunnels / alkcall `src/`: levels, event/field naming, alkgit / alktunnels / alkcall `src/`: levels, event/field naming,
what gets emitted on error and decline paths) and adopt it — no what gets emitted on error and decline paths) and adopt it (no
alkssh-private dialect; record what was read and chosen. alkssh-private dialect); record what was read and chosen.
- **The event vocabulary** — the deciding context on every decline: - **The event vocabulary**, with the deciding context on every
row + feature build + channel id (+ the decline class — open-level decline being row + feature build + channel id (plus the decline
vs request-level vs serving-path, and where applicable the failure class: open-level vs request-level vs serving-path, and where
shape carried). The mapping tasks then emit at their sites. applicable the failure shape carried). The mapping tasks then emit at their sites.
- **Instrument what exists now** — the open-level declines - **Instrument what exists now**: the open-level declines
(unfeatured / never-feature / cap-exceeded), the request-level (unfeatured / never-feature / cap-exceeded), the request-level
capability declines (`subsystem`/`env`/`x11`/`agent`, the capability declines (`subsystem`/`env`/`x11`/`agent`, the
unfeatured-parse routes), the seam-stub serving declines, and the unfeatured-parse routes), the seam-stub serving declines, and the
serve-path skeleton (spawned path started / ended, failure) — the serve-path skeleton (spawned path started / ended, failure), with
stubs' events carry into the mapping tasks' replaced paths. the stubs' events carrying into the mapping tasks' replaced paths.
- **Safety posture** (conventions 6, OQ-03): no secret material ever - **Safety posture** (conventions 6, OQ-03): no secret material ever
enters an event, never the username; identity/fingerprint exposure enters an event, never the username; identity/fingerprint exposure
on decline events is decided here against the family posture and on decline events is decided here against the family posture and
recorded (auth.md: log exposure is deployment policy — the crate's recorded (auth.md: log exposure is deployment policy, and the
events must be safe-by-default at every level). crate's events must be safe-by-default at every level).
Small, cross-cutting unit — one pass over the landed handler/dispatch A single small cross-cutting pass over the landed
sites with the vocabulary, so the three mapping branches never land handler/dispatch sites with the vocabulary, so the three mapping branches never land
inconsistent events. inconsistent events.
## Acceptance Criteria ## Acceptance Criteria
@@ -54,7 +54,7 @@ inconsistent events.
- [x] The safety posture recorded (no secrets, no username; what - [x] The safety posture recorded (no secrets, no username; what
identity detail may appear decided) identity detail may appear decided)
- [x] A test pins the event presence at the decline sites (or the - [x] A test pins the event presence at the decline sites (or the
doc-test convention the family uses — decide + record) doc-test convention the family uses, decided and recorded)
- [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`, - [x] `cargo test`, `cargo clippy --all-targets -- -D warnings`,
`cargo fmt --check`, `cargo doc --no-deps` pass `cargo fmt --check`, `cargo doc --no-deps` pass
@@ -66,7 +66,8 @@ inconsistent events.
classes), §Decline matrix classes), §Decline matrix
- docs/architecture/auth.md §Dispositions (log exposure posture), - docs/architecture/auth.md §Dispositions (log exposure posture),
docs/architecture/decisions/002-russh-pin.md (the toolkit) docs/architecture/decisions/002-russh-pin.md (the toolkit)
- sibling `src/` tracing usage — the convention read this task adopts - sibling `src/` tracing usage, the convention this task reads and
adopts
## Notes ## Notes
@@ -85,8 +86,8 @@ the family's `src/`:
(`listen = …`, `error = %e`, `reason = …`); levels `debug` for (`listen = …`, `error = %e`, `reason = …`); levels `debug` for
structural observations, `warn` for recoverable failures, `error` structural observations, `warn` for recoverable failures, `error`
for abnormal pump terminations. for abnormal pump terminations.
- **alkcall** (`protocol/*`, `channels/*`): the widest use — the same - **alkcall** (`protocol/*`, `channels/*`): the widest use, with the
fields convention (`channel_id`, `request_id`, `error = %e`), same fields convention (`channel_id`, `request_id`, `error = %e`),
component-prefixed messages (`"channel client: …"`, `"open wrapper: component-prefixed messages (`"channel client: …"`, `"open wrapper:
…"`), `debug`/`warn` only. …"`), `debug`/`warn` only.
- **alkhttp** (the closest structural analog): `warn`/`error` with - **alkhttp** (the closest structural analog): `warn`/`error` with
@@ -100,50 +101,52 @@ free `tracing` fn calls, message-last lowercase with the
### The event vocabulary (landed in `src/observe.rs`) ### The event vocabulary (landed in `src/observe.rs`)
- **`feature_build`** — the compile-time feature set as a stable - **`feature_build`**, the compile-time feature set as a stable
string (`"none"`, `"git"`, `"session+git"`, …; features join in the string (`"none"`, `"git"`, `"session+git"`, …; features join in the
order `session, git, tunnel, sftp`), so a captured event is order `session, git, tunnel, sftp`), so a captured event is
self-describing per build; `sftp` ships in the string even though it self-describing per build; `sftp` ships in the string even though it
gates no route (it is part of what the assembly deployed). gates no route (it is part of what the assembly deployed).
- **`row`** — the greppable wire row: the channel type for open-level - **`row`**, the greppable wire row: the channel type for open-level
(`"session"`, `"direct-tcpip"`, `"x11"`, …), the request name for (`"session"`, `"direct-tcpip"`, `"x11"`, …), the request name for
request-level (`"pty_request"`, `"tcpip_forward"`, …), the handler request-level (`"pty_request"`, `"tcpip_forward"`, …), the handler
hook name for auth (`"auth_publickey"`, `"auth_none"`, …). hook name for auth (`"auth_publickey"`, `"auth_none"`, …).
- **`decline_class`** — `"open"` | `"request"` | `"serving"` | - **`decline_class`**, `"open"` | `"request"` | `"serving"` |
`"auth"`: the two wire decline classes (`mapping.md` §Wire decline `"auth"`: the two wire decline classes (`mapping.md` §Wire decline
reasons), plus the serving-path class (this wave the seam stub's reasons), plus the serving-path class (this wave the seam stub's
decline *is* the serving path; the mappings emit the same class from decline *is* the serving path; the mappings emit the same class from
their real spawned paths) and the auth class (the ADR-004 their real spawned paths) and the auth class (the ADR-004
disposition declines — added by this task: the auth hooks are disposition declines, added by this task: the auth hooks are
decline sites too, and `auth.md`'s dispositions are the crate's decline sites too, and `auth.md`'s dispositions are the crate's
auth-decline contract). auth-decline contract).
- **`reason`** — the decline cause, mirroring the architecture's own - **`reason`**, the decline cause mirroring the architecture's own
classifications: `"unfeatured"`, `"never-feature"` (ADR-003 §5), classifications: `"unfeatured"`, `"never-feature"` (ADR-003 §5),
`"cap-exceeded"`, `"connect-failed"` (tunnel stub / ADR-006 `"cap-exceeded"`, `"connect-failed"` (tunnel stub / ADR-006
decision 1), `"seam-stub"` (session-row stubs), `"unimplemented"` decision 1), `"seam-stub"` (session-row stubs), `"unimplemented"`
(subsystem rows — OQ-13 — and the wave-3 listen forms), (the subsystem rows under OQ-13 and the wave-3 listen forms),
`"declined-by-design"` (env — OQ-09), and on the auth class `"declined-by-design"` (env, OQ-09), and on the auth class
`"method-unimplemented"` / `"cert-unsupported"` / `"non-ed25519"` / `"method-unimplemented"` / `"cert-unsupported"` / `"non-ed25519"` /
`"fingerprint-unresolved"`. `"fingerprint-unresolved"`.
- **`channel_id`** — the wire channel id (u32), on every decline of a - **`channel_id`**, the wire channel id (u32), on every decline of a
channel (notably: the event's id is *russh's server-side* id — channel (notably: the event's id is *russh's server-side* id,
`ChannelId` is not publicly constructible, so the helpers take the because `ChannelId` is not publicly constructible, so the helpers take the
raw u32 via `ChannelId::number()`); the auth class and the global raw u32 via `ChannelId::number()`); the auth class and the global
`-R` listen forms carry none (a separate `decline_global_request` `-R` listen forms carry none (a separate `decline_global_request`
helper — global requests have no channel). helper, since global requests have no channel).
- **`exec_route`** — only on `exec_request`-row events: the parse - **`exec_route`**, only on `exec_request`-row events: the parse
route (`"git"` | `"pipe"`), so a git row's decline is route (`"git"` | `"pipe"`), so a git row's decline is
distinguishable from a pipe row's in combined builds. A `None` route distinguishable from a pipe row's in combined builds. A `None` route
field is not recorded at all by `tracing` (discovered by the unit field is not recorded at all by `tracing` (discovered by the unit
rows; recorded in the module docs). The `message` field rides first rows; recorded in the module docs). The `message` field rides first
(the `event!` macro's shape — the unit rows pin the field order). (the `event!` macro's shape, the unit rows pinning the field
order).
Levels: every decline and the lifecycle's started/ended emit at Levels: every decline and the lifecycle's started/ended emit at
`debug` — a decline is the door's normal, honest posture (vanilla `debug`, since a decline is the door's normal, honest posture
(vanilla
clients probe unfeatured rows routinely), matching the family's clients probe unfeatured rows routinely), matching the family's
structural-observation level. Only [`serving_path_failed`] is `warn` structural-observation level. Only [`serving_path_failed`] is `warn`
(a serving path that could not complete its wire reporting is a real (a serving path that could not complete its wire reporting is a real
failure — the family's recoverable-failure level). failure, which is the family's recoverable-failure level).
### The serving-path skeleton ### The serving-path skeleton
@@ -152,40 +155,42 @@ lifecycle events hang off the spawned task's body in
`handler.rs`'s `seam_stub_decline`: started (the spawned task's first `handler.rs`'s `seam_stub_decline`: started (the spawned task's first
action, so "started" is the task's honest start, not the hook's spawn action, so "started" is the task's honest start, not the hook's spawn
call) → the serving-path decline (class `serving`, reason `seam-stub`) call) → the serving-path decline (class `serving`, reason `seam-stub`)
→ ended — or failed (`warn`, with the russh protocol error) when a → ended, or failed (`warn`, with the russh protocol error) when a
report send could not complete. The wave-3 mapping branches report send could not complete. The wave-3 mapping branches
(`w3-git-mapping`, `w3-tty-session`, `w3-tunnel-direct`) inherit the (`w3-git-mapping`, `w3-tty-session`, `w3-tunnel-direct`) inherit the
shaped rows by emitting the same helpers from their real serving tasks. shaped rows by emitting the same helpers from their real serving tasks.
### The safety posture (conventions 6, OQ-03 — recorded) ### The safety posture (conventions 6 and OQ-03, recorded)
**No secret material ever enters an event; never the username; **No secret material ever enters an event; never the username;
the fingerprint stays out too.** Structurally enforced, not recalled: the fingerprint stays out too.** Structurally enforced, not recalled:
the vocabulary helpers take only `&'static str` values and the wire the vocabulary helpers take only `&'static str` values and the wire
channel id — no parameter a username, command string, env name/value, channel id, leaving no parameter a username, command string, env
name/value,
repo path, key, or fingerprint could ride. The `reason` codes carry repo path, key, or fingerprint could ride. The `reason` codes carry
the decline's *cause class*, and the deciding context is the row + the decline's *cause class*, and the deciding context is the row +
build + channel — deliberately *not* the identity behind the wire build + channel, deliberately *not* the identity behind the wire
attempt. This is the recording of auth.md §Dispositions' standing attempt. This is the recording of auth.md §Dispositions' standing
rule at the crate level: log exposure is deployment policy, so the rule at the crate level: log exposure is deployment policy, so the
crate's events are safe-by-default at every level — the fingerprint crate's events are safe-by-default at every level, with the
fingerprint
(and any identity detail) is a deployer-side computation over its own (and any identity detail) is a deployer-side computation over its own
logs, never crate-emitted. If a future decline site needs logs, never crate-emitted. If a future decline site needs
request-argument detail (e.g. the git grammar's decline reasons in request-argument detail (e.g. the git grammar's decline reasons in
`w3-git-mapping`), it must add an explicitly-vocabulary'd field and `w3-git-mapping`), it must add an explicitly-vocabulary'd field and
record its safety reasoning — dynamic strings do not ride record its safety reasoning, since dynamic strings do not ride
`src/observe.rs` by default. The wire rows pin the posture by `src/observe.rs` by default. The wire rows pin the posture by
scanning every captured event: no username string, no `SHA256:` scanning every captured event: no username string, no `SHA256:`
fingerprint, no `user*`-named field ever appears. fingerprint, no `user*`-named field ever appears.
### The test convention (decided + recorded) ### The test convention (decided + recorded)
The family has no event-capture precedent to adopt — alktunnels' The family has no event-capture precedent to adopt: alktunnels'
`tracing-subscriber` dev-dep drives fmt output, not assertions. The `tracing-subscriber` dev-dep drives fmt output, not assertions. The
convention these rows pin: **a hand-rolled minimal convention these rows pin: **a hand-rolled minimal
`tracing::Subscriber`** capturing events into a shared buffer, `tracing::Subscriber`** capturing events into a shared buffer,
installed once as the global default (`tests/observe.rs`) — the installed once as the global default (`tests/observe.rs`), because
server side runs on spawned tasks, so a thread-local the server side runs on spawned tasks, so a thread-local
`with_default` capture cannot see them, and a global capture needs no `with_default` capture cannot see them, and a global capture needs no
new dependency. Two levels of pinning: new dependency. Two levels of pinning:
@@ -195,16 +200,17 @@ new dependency. Two levels of pinning:
lifecycle messages, the `warn` failure arm, and the lifecycle messages, the `warn` failure arm, and the
cfg-derived `feature_build` string. cfg-derived `feature_build` string.
- Wire-level (`tests/observe.rs`, the loopback harness drives the - Wire-level (`tests/observe.rs`, the loopback harness drives the
real wire; capture installed up front via the mount helpers — a real wire; capture installed up front via the mount helpers, since
lazily-installed global loses the earliest emissions): presence by a lazily-installed global loses the earliest emissions): presence by
field-matching at every decline site across the builds — x11 open, field-matching at every decline site across the builds: x11 open,
`-R` listen (global), the fingerprint-unresolved and auth_none `-R` listen (global), the fingerprint-unresolved and auth_none
dispositions (driven on fresh mounts — a second auth exchange on an dispositions (driven on fresh mounts, since a second auth exchange
on an
already-authenticated session is not a supported russh surface, as already-authenticated session is not a supported russh surface, as
the probe discovered), the unfeatured session/pipe/pty rows, the the probe discovered), the unfeatured session/pipe/pty rows, the
git seam-stub's full lifecycle, the tunnel stub's `connect-failed`, git seam-stub's full lifecycle, the tunnel stub's `connect-failed`,
the cap-exceeded row (zero-cap limits must ride the *server* config the cap-exceeded row (zero-cap limits must ride the *server* config
— `serve_config`, not just the client's `Limits`), and the (`serve_config`), not just the client's `Limits`), and the
username/fingerprint safety scan. Build-specific rows follow the username/fingerprint safety scan. Build-specific rows follow the
`dispatch_core.rs` per-build cfg convention and were verified in `dispatch_core.rs` per-build cfg convention and were verified in
the default, `git`, `session`, `tunnel`, and `--all-features` the default, `git`, `session`, `tunnel`, and `--all-features`
+58 -54
View File
@@ -1,6 +1,6 @@
--- ---
id: w3-git-mapping id: w3-git-mapping
name: Wave 3 — the git mapping (exec grammar + alkgit dispatch) name: "Wave 3: the git mapping (exec grammar + alkgit dispatch)"
status: completed status: completed
depends_on: [w3-decline-tracing] depends_on: [w3-decline-tracing]
scope: moderate scope: moderate
@@ -11,28 +11,29 @@ level: implementation
## Description ## Description
Land the git branch of the spine (ADR-006 decision 2, exec-only) — the Land the git branch of the spine (ADR-006 decision 2, exec-only),
alkforge primary purpose: the full exec grammar replaces the wave-2 which is the alkforge primary purpose: the full exec grammar replaces the wave-2
skeleton probe, and the git row becomes a real dispatch to the skeleton probe, and the git row becomes a real dispatch to the
published alkgit seam. Everything POC-3 stage A proved, re-landed as published alkgit seam. Everything POC-3 stage A proved, re-landed as
contract. contract.
- **The full grammar** (replaces `dispatch::classify_exec` — the - **The full grammar** (replaces `dispatch::classify_exec`, the
wave-2 interpretation's named replacement; its 7 probe unit rows wave-2 interpretation's named replacement; its 7 probe unit rows
migrate): `git-{upload,receive}-pack '<path>'` — quoted/unquoted migrate): `git-{upload,receive}-pack '<path>'` handles quoted/unquoted
paths; strip the quotes and the leading `/` (POC-3 stage A rows: paths; strip the quotes and the leading `/` (POC-3 stage A rows:
what real OpenSSH git sends); unknown services what real OpenSSH git sends); unknown services
(`git-send-pack`, `git-upload-archive`) → decline-with-reason, the (`git-send-pack`, `git-upload-archive`) → decline-with-reason, the
honest surface; service-only (no repo) → decline-with-reason — honest surface; service-only (no repo) → decline-with-reason (POC-3
POC-3 §A: SSH exec must carry the repo; per-length limits consumed §A: SSH exec must carry the repo); per-length limits consumed
at last — `Limits::max_exec_command_len` enforced **before** any at last, with `Limits::max_exec_command_len` enforced **before**
any
parse or allocation (decline-first, alloc-never; the wave-2 parse or allocation (decline-first, alloc-never; the wave-2
carried-not-enforced reservation). Decide the wire shapes per carried-not-enforced reservation). Decide the wire shapes per
decline class (open/reply vocabulary, whether "unknown service" and decline class (open/reply vocabulary, whether "unknown service" and
"not git at all" share a reason shape) and record. "not git at all" share a reason shape) and record.
- **Repo path → repo_id, no existence oracle**: the parse validates - **Repo path → repo_id, no existence oracle**: the parse validates
via `alkgit::validate_repo_id` (1–2 segments, charset, ≤ 100 bytes) via `alkgit::validate_repo_id` (1–2 segments, charset, ≤ 100 bytes)
and hands the id through — alkssh never resolves; the and hands the id through, alkssh never resolving; the
**collapsed generic denial before any protocol byte** is **collapsed generic denial before any protocol byte** is
`serve_duplex_resolved`'s `SubstrateError::AccessDenied` posture, `serve_duplex_resolved`'s `SubstrateError::AccessDenied` posture,
contract-pinned here: over the SSH channel, no pack/ref byte contract-pinned here: over the SSH channel, no pack/ref byte
@@ -40,30 +41,30 @@ contract.
test row). Decide the `.git`-suffix normalization against the test row). Decide the `.git`-suffix normalization against the
alkgit registry contract (what vanilla git sends vs alkgit registry contract (what vanilla git sends vs
`validate_repo_id`'s grammar) and record. `validate_repo_id`'s grammar) and record.
- **The dispatch** — the git row becomes real: the taken channel - **The dispatch**: the git row becomes real, and the taken channel
(the store-and-take handoff lands **here first** — the accepted (the store-and-take handoff lands **here first**; the accepted
channel is stored at the open, `channels.remove(&id)` → channel is stored at the open, `channels.remove(&id)` →
`into_stream()` in the spawned serving task; the session task `into_stream()` in the spawned serving task; the session task
reuses the store) → `alkgit::serve_duplex_resolved(repo_id, reuses the store) → `alkgit::serve_duplex_resolved(repo_id,
identity, service, duplex, limits, registry, refs, packgen, identity, service, duplex, limits, registry, refs, packgen,
ingest, push_options_negotiated, pipeline)` — the serve-config's ingest, push_options_negotiated, pipeline)`, namely the
`git` seam handle (`w3-seam-config`), the resolved identity riding serve-config's `git` seam handle (`w3-seam-config`), the resolved identity riding
the `identity` param. Process-mode dialect: alkgit speaks first the `identity` param. Process-mode dialect: alkgit speaks first
(the V0 advertisement, no service prefix) — nothing alkssh-side; (the V0 advertisement, no service prefix), and nothing is
verify the dialect holds over the channel duplex with a byte-level alkssh-side. Verify the dialect holds over the channel duplex with a byte-level
row. row.
- **Exit-status both directions** (POC-3 §A.5): clean serve end → - **Exit-status both directions** (POC-3 §A.5): clean serve end →
`exit-status(0)`... decide the code mapping (`SubstrateError` `exit-status(0)`... decide the code mapping (`SubstrateError`
outcomes → the code — POC evidence: denied → the client observed outcomes → the code; POC evidence: denied → the client observed
`ExitStatus{1}`; vanilla git exits 0 on success) — sent before `ExitStatus{1}`; vanilla git exits 0 on success), sent before
close, from the spawned task via the cloned `Session::handle()` close, from the spawned task via the cloned `Session::handle()`
(the wave-2 mechanics reused). Record the decision. (the wave-2 mechanics reused). Record the decision.
- **Identity** — the parked wave-2 deferred item discharges here: - **Identity**: the parked wave-2 deferred item discharges here, and
the resolved identity (the bridge's `auth_succeeded` ride-along) the resolved identity (the bridge's `auth_succeeded` ride-along)
reaches the seam — assert it live (the authorize-path/witness row reaches the seam, so assert it live (the authorize-path/witness row
the harness can observe; decide the cheapest row) so the the harness can observe; decide the cheapest row) so the
visible-surface invariant is observable through the git row. visible-surface invariant is observable through the git row.
- **Contract rows through the harness, per build** — the - **Contract rows through the harness, per build**, with the
`tests/dispatch_core.rs` skeleton rows upgraded: git-only serves `tests/dispatch_core.rs` skeleton rows upgraded: git-only serves
the git row through the doubles (clone/push semantics over the the git row through the doubles (clone/push semantics over the
in-mem composition), declines plain exec/pty/shell (parse-then-fail, in-mem composition), declines plain exec/pty/shell (parse-then-fail,
@@ -72,12 +73,12 @@ contract.
routing holds. The declined rows keep their wire shapes routing holds. The declined rows keep their wire shapes
(`channel_failure`, channel usable / serving-path failure+close per (`channel_failure`, channel usable / serving-path failure+close per
class). class).
- **The real-tool smoke** — one `git clone` (2.43+) through the - **The real-tool smoke**: one `git clone` (2.43+) through the
harness against hand-rolled in-mem alkgit doubles (alkgit's `Mem*` harness against hand-rolled in-mem alkgit doubles (alkgit's `Mem*`
are test-private — alkssh hand-rolls the minimal quartet per are test-private, so alkssh hand-rolls the minimal quartet per
`alkgit/src/backend.rs:164-254`'s noop shapes; never sibling-checkout `alkgit/src/backend.rs:164-254`'s noop shapes; never sibling-checkout
paths, never copies of the doubles' code). Decide the tooling paths, never copies of the doubles' code). Decide the tooling
posture (require git in the environment vs conditional-skip — posture (require git in the environment vs conditional-skip,
skipped-with-reason at most, never silently green) and record. skipped-with-reason at most, never silently green) and record.
## Acceptance Criteria ## Acceptance Criteria
@@ -106,10 +107,10 @@ contract.
## References ## References
- docs/architecture/mapping.md (the git channel-type row; §Session- - docs/architecture/mapping.md (the git channel-type row; §Session-
channel dispatch — decline-first-alloc-never; §the structural channel dispatch and its decline-first-alloc-never rule; §the
constraint) structural constraint)
- docs/architecture/decisions/006-mapping-bridge-and-dispatch.md - docs/architecture/decisions/006-mapping-bridge-and-dispatch.md
(decision 2 — exec-only; decision 4 — spawn-and-return) (decision 2, exec-only; decision 4, spawn-and-return)
- docs/architecture/decisions/004-auth-dispositions.md (the identity - docs/architecture/decisions/004-auth-dispositions.md (the identity
ride), docs/architecture/auth.md §Server direction (step 4) ride), docs/architecture/auth.md §Server direction (step 4)
- docs/research/poc-3-mapping-bakeoff.md §Stage A (the grammar rows, - docs/research/poc-3-mapping-bakeoff.md §Stage A (the grammar rows,
@@ -126,7 +127,8 @@ contract.
### Decisions recorded (implementation, 2026-10-28) ### Decisions recorded (implementation, 2026-10-28)
- **Course correction to `w3-seam-config`**: `GitSeam` gains - **Course correction to `w3-seam-config`**: `GitSeam` gains
`limits: alkgit::Limits` — `serve_duplex_resolved` requires the `limits: alkgit::Limits`, since `serve_duplex_resolved` requires
the
alkgit ADR-009 session budget, which the landed handle had no field alkgit ADR-009 session budget, which the landed handle had no field
for. alkgit's own contract shapes this ("constructed by the for. alkgit's own contract shapes this ("constructed by the
door/adapter from assembler config"), so the budget rides the seam door/adapter from assembler config"), so the budget rides the seam
@@ -135,34 +137,35 @@ contract.
docs. docs.
- **The grammar** (`dispatch.rs`, the probe's rows migrated): exact - **The grammar** (`dispatch.rs`, the probe's rows migrated): exact
serving-service token + single separating space + path-shaped serving-service token + single separating space + path-shaped
argument (the wave-2 probe's pinned verdicts preserved verbatim — argument (the wave-2 probe's pinned verdicts preserved verbatim:
`git-upload-pack rel` and double-space arguments are *not git*); `git-upload-pack rel` and double-space arguments are *not git*);
quoted shape is fully `'`-wrapped (unterminated → pipe, the probe's quoted shape is fully `'`-wrapped (unterminated → pipe, the probe's
non-path-shape verdict); leading `/` stripped after quotes; **one non-path-shape verdict); leading `/` stripped after quotes; **one
trailing `.git` suffix stripped** (the vanilla-clone idiom; the trailing `.git` suffix stripped** (the vanilla-clone idiom; the
alkgit registry grammar excludes `.`, so the door normalizes the alkgit registry grammar excludes `.`, so the door normalizes the
idiom rather than asking registries for dotted keys; an empty idiom rather than asking registries for dotted keys; an empty
remainder — `/.git` — is *kept* and the git row's validation remainder (`/.git`) is *kept* and the git row's validation
declines it, never a silent emptying). Validation: declines it, never a silent emptying). Validation:
`alkgit::validate_repo_id` at the git row (`git_map.rs`, `alkgit::validate_repo_id` at the git row (`git_map.rs`,
feature-gated — the parse core stays feature-free); a shape-invalid feature-gated, while the parse core stays feature-free); a
shape-invalid
id declines `invalid-repo-id` alkssh-side; a shape-valid id is id declines `invalid-repo-id` alkssh-side; a shape-valid id is
handed through untouched and **alkssh never resolves** (resolution, handed through untouched and **alkssh never resolves** (resolution,
authorize, and the collapsed generic denial live at the seam). authorize, and the collapsed generic denial live at the seam).
Unknown services (`git-send-pack`, `git-upload-archive`) and Unknown services (`git-send-pack`, `git-upload-archive`) and
service-only (including an empty argument) route to service-only (including an empty argument) route to
`ExecRoute::Declined` — build-independent, off the pipe row, per `ExecRoute::Declined` (build-independent, off the pipe row, per
POC-3 §2's honest-surface verdict. POC-3 §2's honest-surface verdict.
- **Wire shapes per decline class** (recorded): SSH request replies - **Wire shapes per decline class** (recorded): SSH request replies
carry no text, so the reason lives only in the event vocabulary; carry no text, so the reason lives only in the event vocabulary;
the git declinables (`unknown-service` / `service-only` / the git declinables (`unknown-service` / `service-only` /
`invalid-repo-id` / `exec-oversize`) share the request-level reply `invalid-repo-id` / `exec-oversize`) share the request-level reply
shape with parse-then-fail — `channel_failure`, channel alive shape with parse-then-fail: `channel_failure`, channel alive
(POC-3's decline shape, POC-1 §5 posture); the reply vocabulary is (POC-3's decline shape, POC-1 §5 posture); the reply vocabulary is
the decision ("unknown service" vs "not git at all" differ in the decision ("unknown service" vs "not git at all" differ in
nothing observable on the wire — only the event). Serving-path nothing observable on the wire, only the event). Serving-path
declines (the row owned the channel) keep failure+close: declines (the row owned the channel) keep failure+close:
`seam-unconfigured` (feature on, seam unset — the unset-declines `seam-unconfigured` (feature on, seam unset, the unset-declines
contract; keeps the wave-2 decline-matrix rows' shape in builds contract; keeps the wave-2 decline-matrix rows' shape in builds
without a configured seam), plus the unreachable-in-practice without a configured seam), plus the unreachable-in-practice
guards `no-identity` and `channel-not-held`. The *serve's own* guards `no-identity` and `channel-not-held`. The *serve's own*
@@ -174,45 +177,46 @@ contract.
- **Exit mapping** (POC-3 §A.5, decided): `Ok(())` → exit-status(0) - **Exit mapping** (POC-3 §A.5, decided): `Ok(())` → exit-status(0)
(vanilla git exits 0 on success; the clean-end wire row exercises (vanilla git exits 0 on success; the clean-end wire row exercises
the flush-only request's `Ok(None)` → `Ok(())` shape); every the flush-only request's `Ok(None)` → `Ok(())` shape); every
`SubstrateError` → exit-status(1) — `AccessDenied` per the POC's `SubstrateError` → exit-status(1): `AccessDenied` per the POC's
wire evidence, `Io`/`Protocol` the same (the service ran and wire evidence, `Io`/`Protocol` the same (the service ran and
failed), the machinery-misuse variants defensively 1 (they never failed), the machinery-misuse variants defensively 1 (they never
arise from the serving entry). `255` never fabricated (OpenSSH's arise from the serving entry). `255` never fabricated (OpenSSH's
could-not-start semantic this door never claims). Status before could-not-start semantic this door never claims). Status before
close, from the spawned task via the cloned handle; a failed report close, from the spawned task via the cloned handle; a failed report
send is the vocabulary's one `warn` (the wave-2 stub mechanics). A send is the vocabulary's one `warn` (the wave-2 stub mechanics). A
serve `Err` is *not* a decline event — the substrate already mapped serve `Err` is *not* a decline event, as the substrate already
its own error onto the wire. mapped its own error onto the wire.
- **Store-and-take handoff**: `SessionHandler.channels` (plain - **Store-and-take handoff**: `SessionHandler.channels` (plain
`std` HashMap, single-writer — every mutation on the packet loop's `std` HashMap, single-writer, with every mutation on the packet
task). Stored at the confirmed open (accept then insert); taken by loop's task). Stored at the confirmed open (accept then insert); taken by
the git row (`remove` → `into_stream()` in the spawned serving the git row (`remove` → `into_stream()` in the spawned serving
task); released by `channel_close` (the cap is released there too — task); released by `channel_close` (the cap is released there too:
one hook, both budgets). The session rows still hold until their one hook, both budgets). The session rows still hold until their
wave-3 tasks take them. A routed exec answers `channel_success` wave-3 tasks take them. A routed exec answers `channel_success`
inline (the POC-3 §A.5 shape — the request routed; the serving inline (the POC-3 §A.5 shape, where the request routed and the
outcome reports through exit-status). serving outcome reports through exit-status).
- **Identity** (`w2-auth-bridge`'s parked ride-along, discharged): - **Identity** (`w2-auth-bridge`'s parked ride-along, discharged):
the resolved identity is cloned into the serving task and passed on the resolved identity is cloned into the serving task and passed on
`serve_duplex_resolved`'s identity param. The cheapest live witness `serve_duplex_resolved`'s identity param. The cheapest live witness
row: **alkgit's `authorize` evaluation** — two otherwise-identical row: **alkgit's `authorize` evaluation**, giving two
otherwise-identical
rows (private repo granted to the riding id → the advertisement rows (private repo granted to the riding id → the advertisement
flows; the same repo ungranted → the denial band + exit 1); no flows; the same repo ungranted → the denial band + exit 1); no
direct identity introspection needed, and the deny row doubles as direct identity introspection needed, and the deny row doubles as
the authorize-path witness. `None` can never occur post-auth and the authorize-path witness. `None` can never occur post-auth and
declines serving-path. declines serving-path.
- **Push semantics through the doubles**: the packless CAS path - **Push semantics through the doubles**: the packless CAS path
(alkgit ADR-020 §1) is the pack-free receive-pack row — commands + (alkgit ADR-020 §1) is the pack-free receive-pack row, with commands +
flush (caps NUL-attached on the first command line, `report-status` flush (caps NUL-attached on the first command line, `report-status`
requested), no pack; the wire layer applies via requested), no pack; the wire layer applies via
`GitRefs::apply_updates` and writes the report (`unpack ok` / `GitRefs::apply_updates` and writes the report (`unpack ok` /
`ok <ref>`). The duplex discipline *drains the raw tail packless* `ok <ref>`). The duplex discipline *drains the raw tail packless*,
— the row had to signal client-side EOF after the report flush which is why the row had to signal client-side EOF after the report
(observed; the real client-eof shape), which is also why vanilla flush (observed, the real client-eof shape) and also why vanilla
delete-pushes end cleanly. `GitPackIngest::prepare` never fires delete-pushes end cleanly. `GitPackIngest::prepare` never fires
packless — asserted. packless, as asserted.
- **Per-build matrix**: unchanged rows in `tests/dispatch_core.rs` - **Per-build matrix**: unchanged rows in `tests/dispatch_core.rs`
(their observables hold — the config-driven decline keeps the (their observables hold, since the config-driven decline keeps the
failure-then-close shape in seam-less builds; unknown services keep failure-then-close shape in seam-less builds; unknown services keep
failure-then-alive), docs/stamps updated for the new decision set; failure-then-alive), docs/stamps updated for the new decision set;
the git row's serving + grammar-decline rows live in the new the git row's serving + grammar-decline rows live in the new
@@ -220,11 +224,11 @@ contract.
feature isolation). `tests/observe.rs`'s git-row event row updated feature isolation). `tests/observe.rs`'s git-row event row updated
to the `seam-unconfigured` reason. to the `seam-unconfigured` reason.
- **Real-tool smoke posture** (recorded): the clone smoke (a real - **Real-tool smoke posture** (recorded): the clone smoke (a real
`git clone` of an unborn-HEAD repo over a TCP-mounted adapter — the `git clone` of an unborn-HEAD repo over a TCP-mounted adapter, on
same loopback server stack, the accept loop as the assembly's the same loopback server stack, the accept loop as the assembly's
mount) **requires git ≥ 2.43 and ssh in the environment**; mount) **requires git ≥ 2.43 and ssh in the environment**;
missing/older tooling is skipped *with a printed reason* — missing/older tooling is skipped *with a printed reason*
skipped-with-reason at most, never silently green. The empty-repo (skipped-with-reason at most, never silently green). The empty-repo
clone exercises the full chain (exec parse → dispatch → the seam clone exercises the full chain (exec parse → dispatch → the seam
advertisement → ls-refs → exit-status(0)) with zero pack bytes: the advertisement → ls-refs → exit-status(0)) with zero pack bytes: the
unborn-HEAD line is what real git 2.43's clone consumed (POC-3's unborn-HEAD line is what real git 2.43's clone consumed (POC-3's
@@ -233,4 +237,4 @@ contract.
--features git --test git_mapping`, `cargo test --features session`, --features git --test git_mapping`, `cargo test --features session`,
`cargo test --all-features`, `cargo clippy --all-targets --all- `cargo test --all-features`, `cargo clippy --all-targets --all-
features -- -D warnings` ×2 (default + all-features), `cargo fmt features -- -D warnings` ×2 (default + all-features), `cargo fmt
--check`, `cargo doc --no-deps` — all green. --check`, and `cargo doc --no-deps`: all green.
+27 -24
View File
@@ -1,6 +1,6 @@
--- ---
id: w3-seam-config id: w3-seam-config
name: Wave 3 — serve-config seam handles (alktty/alkgit per-feature) name: "Wave 3: serve-config seam handles (alktty/alkgit per-feature)"
status: completed status: completed
depends_on: [review-wave-2, w1-config] depends_on: [review-wave-2, w1-config]
scope: narrow scope: narrow
@@ -13,7 +13,7 @@ level: implementation
Extend [`crate::ServeConfig`] with the per-feature family-seam handles Extend [`crate::ServeConfig`] with the per-feature family-seam handles
the wave-3 mappings consume. This is wave 3's first *course the wave-3 mappings consume. This is wave 3's first *course
correction* to the wave-1 config surface — the assembly-supplied correction* to the wave-1 config surface: the assembly-supplied
composition inputs were never carried (mapping.md's seam-entries table composition inputs were never carried (mapping.md's seam-entries table
names them inside the seam signatures; the wave-1 config bullet names them inside the seam signatures; the wave-1 config bullet
pre-dates that reading), and the mappings cannot call pre-dates that reading), and the mappings cannot call
@@ -25,17 +25,17 @@ un-set-declines posture structural and the review surface unified:
rows decline even with the feature on (the decline matrix's "an rows decline even with the feature on (the decline matrix's "an
unset field declines its row though the feature is on" note, unset field declines its row though the feature is on" note,
config-driven). Contents, per alktty 0.8.1's actual config-driven). Contents, per alktty 0.8.1's actual
`drive_session[_pre_negotiated]` signature — decide the exact final `drive_session[_pre_negotiated]` signature; decide the exact final
shape (flat `Option` fields vs an `Option`-struct per seam; record): shape (flat `Option` fields vs an `Option`-struct per seam; record):
- the alktty backends map (`Arc<HashMap<String, - the alktty backends map (`Arc<HashMap<String,
Arc<dyn TtyBackend>>>` — assembled by the assembly; alkssh never Arc<dyn TtyBackend>>>`, assembled by the assembly; alkssh never
touches a real backend, the `local` shapes stay alktty-side); touches a real backend, the `local` shapes stay alktty-side);
- the backend **selector** — the `NegotiateRequest.backend` string - the backend **selector**, the `NegotiateRequest.backend` string
alkssh writes (assembly-supplied, never wire-derived; decide and alkssh writes (assembly-supplied, never wire-derived; decide and
record); record);
- the ownership provider `Option<Arc<dyn - the ownership provider `Option<Arc<dyn
alkcall::core::ownership::OwnershipProvider>>` (an alkcall type — alkcall::core::ownership::OwnershipProvider>>` (an alkcall type,
no new dep; pass-through to `drive_session_pre_negotiated`, so no new dep; pass-through to `drive_session_pre_negotiated`,
family ACL layer riding along). family ACL layer riding along).
- **The `git` seam handle** (feature-gated), unset → the git rows - **The `git` seam handle** (feature-gated), unset → the git rows
decline even with the feature on. Contents, per alkgit 0.1.0's decline even with the feature on. Contents, per alkgit 0.1.0's
@@ -45,15 +45,16 @@ un-set-declines posture structural and the review surface unified:
and `push_options_negotiated: bool` (the assembler's config flag; and `push_options_negotiated: bool` (the assembler's config flag;
false in the v1 default). false in the v1 default).
- **Manifest** (the wave-1 rule: per-feature sibling deps land with - **Manifest** (the wave-1 rule: per-feature sibling deps land with
their first consuming wave — the config fields consume them first, their first consuming wave, and the config fields consume them
so they are not dead entries): optional deps first, so they are not dead entries): optional deps
`alktty = "0.8.1"`, `alkgit = "0.1.0"` wired feature-to-dep `alktty = "0.8.1"`, `alkgit = "0.1.0"` wired feature-to-dep
(`session = ["dep:alktty"]`, `git = ["dep:alkgit"]`) so a git-build (`session = ["dep:alktty"]`, `git = ["dep:alkgit"]`) so a git-build
never compiles alktty and vice versa; verify the pins against never compiles alktty and vice versa; verify the pins against
crates.io before committing (verified at decomposition: 0.8.1 / crates.io before committing (verified at decomposition: 0.8.1 /
0.1.0, both over the shared `alkcall 0.10` req → 0.10.1; MSRVs 1.88 0.1.0, both over the shared `alkcall 0.10` req → 0.10.1; MSRVs 1.88
≤ our 1.89; MIT OR Apache-2.0). **alktunnels is NOT landed here** — ≤ our 1.89; MIT OR Apache-2.0). **alktunnels is NOT landed here**,
the config carries no alktunnels types beyond the already-landed since the config carries no alktunnels types beyond the
already-landed
`Arc<ChannelClient>`; its optional dep lands with its first `Arc<ChannelClient>`; its optional dep lands with its first
consuming code (`w3-tunnel-direct`). consuming code (`w3-tunnel-direct`).
@@ -98,7 +99,7 @@ each seam handle carries).
- `Cargo.toml`: optional `alktty = "0.8.1"` / - `Cargo.toml`: optional `alktty = "0.8.1"` /
`alkgit = "0.1.0"` (both `default-features = false`), wired `alkgit = "0.1.0"` (both `default-features = false`), wired
`session = ["dep:alktty"]`, `git = ["dep:alkgit"]`. alktty's `local` `session = ["dep:alktty"]`, `git = ["dep:alkgit"]`. alktty's `local`
feature stays off — alkssh never touches a real backend; the `local` feature stays off, since alkssh never touches a real backend; the `local`
shapes stay alktty-side. Feature-graph comment updated (wave-3 shapes stay alktty-side. Feature-graph comment updated (wave-3
seam-config is the deps' first consumer; alktunnels still lands with seam-config is the deps' first consumer; alktunnels still lands with
`w3-tunnel-direct`). `w3-tunnel-direct`).
@@ -114,18 +115,20 @@ each seam handle carries).
`GitSeam{registry, refs, packgen, ingest, push_options_negotiated, `GitSeam{registry, refs, packgen, ingest, push_options_negotiated,
pipeline}` held as `cfg`-gated `Option` fields pipeline}` held as `cfg`-gated `Option` fields
(`session_seam` / `git_seam`), each `Clone` (all payload types `Arc` (`session_seam` / `git_seam`), each `Clone` (all payload types `Arc`
or plain data — the spawned serving tasks clone the seam), each with a or plain data, as the spawned serving tasks clone the seam), each with a
hand-rolled `Debug`. **Flat `Option` fields were rejected**: each seam hand-rolled `Debug`. **Flat `Option` fields were rejected**: each seam
is consumed atomically by exactly one seam call is consumed atomically by exactly one seam call
(`drive_session[_pre_negotiated]` / `serve_duplex_resolved`), so a (`drive_session[_pre_negotiated]` / `serve_duplex_resolved`), so a
struct admits no half-supplied seam states (backends without a struct admits no half-supplied seam states (backends without a
selector; three traits without the fourth) that would all map to the selector; three traits without the fourth) that would all map to the
same all-or-nothing decline — the unset-declines contract reads as one same all-or-nothing decline, which is why the unset-declines contract
reads as one
`Option` per seam, "unset → the owned rows decline even though the `Option` per seam, "unset → the owned rows decline even though the
feature is on" (the decline matrix's config-driven note), checked at feature is on" (the decline matrix's config-driven note), checked at
request time by the mappings, never at construction. The already-landed request time by the mappings, never at construction. The already-landed
tunnel handle stays the flat tunnel handle stays the flat
`Option<Arc<ChannelClient>>` — a single-field seam needs no struct. `Option<Arc<ChannelClient>>`, since a single-field seam needs no
struct.
Naming: struct names carry the crate (the consuming mappings will see Naming: struct names carry the crate (the consuming mappings will see
it in the field docs); the fields carry the row vocabulary it in the field docs); the fields carry the row vocabulary
(`session_seam` / `git_seam`, matching the decline matrix's row names). (`session_seam` / `git_seam`, matching the decline matrix's row names).
@@ -134,16 +137,16 @@ it in the field docs); the fields carry the row vocabulary
- `alktty 0.8.1`: published 2026-10-22, not yanked, - `alktty 0.8.1`: published 2026-10-22, not yanked,
`MIT OR Apache-2.0`, `rust_version = "1.88"` ≤ our 1.89; `MIT OR Apache-2.0`, `rust_version = "1.88"` ≤ our 1.89;
`alkcall ^0.10.1` — shares our `alkcall = "0.10"` resolution `alkcall ^0.10.1`, which shares our `alkcall = "0.10"` resolution
(0.10.1), so no alkcall fork in the lockfile. (0.10.1), so no alkcall fork in the lockfile.
- `alkgit 0.1.0`: `MIT OR Apache-2.0`, `rust_version = "1.88"`; - `alkgit 0.1.0`: `MIT OR Apache-2.0`, `rust_version = "1.88"`;
`alkcall ^0.10` — same shared resolution. `alkcall ^0.10`, the same shared resolution.
- Signatures confirmed against the published crates (the registry - Signatures confirmed against the published crates (the registry
sources under `~/.cargo/registry/src/`): alktty's sources under `~/.cargo/registry/src/`): alktty's
`drive_session[_pre_negotiated]` (adapter.rs:205-241) takes `drive_session[_pre_negotiated]` (adapter.rs:205-241) takes
`(writer, reader, [req,] backends: Arc<HashMap<String, Arc<dyn `(writer, reader, [req,] backends: Arc<HashMap<String, Arc<dyn
TtyBackend>>>, ownership: Option<Arc<dyn OwnershipProvider>>, TtyBackend>>>, ownership: Option<Arc<dyn OwnershipProvider>>,
identity: Option<Identity>)` — the identity is the per-connection identity: Option<Identity>)`, where the identity is the per-connection
resolved one, not config; alkgit's `serve_duplex_resolved` resolved one, not config; alkgit's `serve_duplex_resolved`
(duplex.rs:1412) takes the four traits + (duplex.rs:1412) takes the four traits +
`push_options_negotiated: bool` + `pipeline: Arc<Semaphore>`. `push_options_negotiated: bool` + `pipeline: Arc<Semaphore>`.
@@ -154,7 +157,7 @@ it in the field docs); the fields carry the row vocabulary
### One course correction absorbed: `ObjectId` is not re-exported by alkgit ### One course correction absorbed: `ObjectId` is not re-exported by alkgit
`GitPackGen::generate`/`common_haves` name `Vec<gix_hash::ObjectId>` `GitPackGen::generate`/`common_haves` name `Vec<gix_hash::ObjectId>`
and alkgit's lib root does not re-export `ObjectId` — a backend-trait and alkgit's lib root does not re-export `ObjectId`; a backend-trait
doubled in tests has to name the type, so `gix-hash = "0.28"` (matching doubled in tests has to name the type, so `gix-hash = "0.28"` (matching
alkgit's `^0.28` req; the same crate already in the lockfile) lands as alkgit's `^0.28` req; the same crate already in the lockfile) lands as
a **`[dev-dependencies]` entry only**, exercised exclusively by the a **`[dev-dependencies]` entry only**, exercised exclusively by the
@@ -163,16 +166,15 @@ feature-to-dep isolation below; the dev graph is universal by design
(tokio and friends are too). Flagged for `w3-git-mapping`: the (tokio and friends are too). Flagged for `w3-git-mapping`: the
hand-rolled in-mem quartet will lean on this dev-dep for `ObjectId` hand-rolled in-mem quartet will lean on this dev-dep for `ObjectId`
(same as this task's noop doubles, modeled on alkgit's own (same as this task's noop doubles, modeled on alkgit's own
`backend.rs:164-254` noop shapes — own code, not copied). `backend.rs:164-254` noop shapes; own code, not copied).
### Dependency isolation — verified structurally, not by assertion ### Dependency isolation, verified structurally not by assertion
`cargo tree -e features --features git`: zero `alktty` occurrences; `cargo tree -e features --features git`: zero `alktty` occurrences;
`cargo tree -e features --features session`: zero `alkgit` `cargo tree -e features --features session`: zero `alkgit`
occurrences; `--all-features`: both present. Combined with the cfg occurrences; `--all-features`: both present. Combined with the cfg
gates (all `alktty`/`alkgit` type references live inside gates (all `alktty`/`alkgit` type references live inside
`#[cfg(feature = "session")]` / `#[cfg(feature = "git")]` blocks — `#[cfg(feature = "session")]` / `#[cfg(feature = "git")]` blocks (imports, fields, accessors, Debug, constructor, tests), the "a
imports, fields, accessors, Debug, constructor, tests), the "a
git-build never compiles alktty and vice versa" invariant is git-build never compiles alktty and vice versa" invariant is
compile-proven for both consumption sides. compile-proven for both consumption sides.
@@ -181,7 +183,8 @@ compile-proven for both consumption sides.
The wave-3 mappings own the request-time decline checks; this task The wave-3 mappings own the request-time decline checks; this task
lands the config-level contract rows (constructor posture, `Option` lands the config-level contract rows (constructor posture, `Option`
configured() semantics, seam-vs-OQ-08-field independence, Debug configured() semantics, seam-vs-OQ-08-field independence, Debug
posture — 8 unit rows across the two feature modules) and leaves the posture, making 8 unit rows across the two feature modules) and
leaves the
wave-2 harness rows untouched (featured-but-unset already declines at wave-2 harness rows untouched (featured-but-unset already declines at
the row the stubs own; the real serving paths make those rows the row the stubs own; the real serving paths make those rows
meaningful in `w3-git-mapping` / `w3-tty-session`, whose seam meaningful in `w3-git-mapping` / `w3-tty-session`, whose seam
+82 -79
View File
@@ -1,6 +1,6 @@
--- ---
id: w3-tty-session id: w3-tty-session
name: Wave 3 — session → alktty (translation bridge + exit derivation) name: "Wave 3: session → alktty (translation bridge + exit derivation)"
status: completed status: completed
depends_on: [w3-git-mapping] depends_on: [w3-git-mapping]
scope: moderate scope: moderate
@@ -11,86 +11,86 @@ level: implementation
## Description ## Description
Land the session branch — the biggest single-row surface of the spine Land the session branch, the biggest single-row surface of the spine
(mapping.md's `session` rows): pty/shell/pipe-exec become a real (mapping.md's `session` rows): pty/shell/pipe-exec become a real
serving path through the published alktty seam, as a **translation serving path through the published alktty seam, as a **translation
bridge, not a byte pump** (POC-3 §D.1). Everything POC-3 stage D bridge, not a byte pump** (POC-3 §D.1). Everything POC-3 stage D
proved, re-landed as contract: proved, re-landed as contract:
- **The per-channel state + the store-and-take reuse** — the channel - **The per-channel state + the store-and-take reuse**: the channel
store `w3-git-mapping` lands (`channels.remove(&id)` → store `w3-git-mapping` lands (`channels.remove(&id)` →
`into_stream()` in the spawned serving task) gains the session `into_stream()` in the spawned serving task) gains the session
rows' per-channel state: the PTY params a `pty_request` delivered rows' per-channel state, the PTY params a `pty_request` delivered
(consumed by the later `shell_request`/`exec_request` on the same (consumed by the later `shell_request`/`exec_request` on the same
channel — `ssh -t host "cmd"` sends pty-then-exec and must serve channel, since `ssh -t host "cmd"` sends pty-then-exec and must
PTY-mode; a `shell_request` without a preceding pty serves serve in PTY-mode; a `shell_request` without a preceding pty serves
**pipe-mode** `tty = None` — decide whether such a shell serves or **pipe-mode** `tty = None`, and whether such a shell serves or
declines and record). declines is for the task to decide and record).
- **Negotiation, built locally, never wire-derived** (OQ-08 — the - **Negotiation, built locally, never wire-derived** (OQ-08: the
serve-config is the whole truth): `pty_request` args → serve-config is the whole truth): `pty_request` args →
`TerminalParamsWire{term, cols, rows, pixel_w/h, modes}` (SSH `TerminalParamsWire{term, cols, rows, pixel_w/h, modes}` (SSH
ttymodes → the reserved modes JSON — alktty OQ-44's reservation, ttymodes → the reserved modes JSON, which is alktty OQ-44's
deferred there); `shell_request` → `NegotiateRequest{carriage: reservation, deferred there); `shell_request` → `NegotiateRequest{carriage:
"raw", cmd: shell_argv (the serve-config shell, verbatim), cwd: "raw", cmd: shell_argv (the serve-config shell, verbatim), cwd:
session_cwd, env: session_env, backend: the seam selector}` → session_cwd, env: session_env, backend: the seam selector}` →
**`drive_session_pre_negotiated(writer, reader, req, backends, **`drive_session_pre_negotiated(writer, reader, req, backends,
ownership, identity)`** spawned; `exec_request` (pipe row) → `cmd = ownership, identity)`** spawned; `exec_request` (pipe row) → `cmd =
wrapper ++ [cmd]` (the OQ-08 argv append — vanilla quoting wrapper ++ [cmd]` (the OQ-08 argv append with vanilla quoting
semantics; **never naively split**), `tty = None` pipe mode. semantics; **never naively split**), `tty = None` pipe mode.
**Unset-declines** (OQ-08): `shell_argv` unset → `shell_request` **Unset-declines** (OQ-08): `shell_argv` unset → `shell_request`
declines; `exec_wrapper_argv` unset → the pipe row declines — never declines; `exec_wrapper_argv` unset → the pipe row declines (never
a fallback. Decide the wire shape per row (capability decline — a fallback). Decide the wire shape per row (capability decline,
channel stays usable — vs the serving-path failure+close shape) and channel stays usable, vs the serving-path failure+close shape) and
record. record.
- **The translation bridge** — two small adapters around a duplex the - **The translation bridge**, two small adapters around a duplex the
serving task drives (the POC-3 §D.1 shape): SSH `data`/EOF → serving task drives (the POC-3 §D.1 shape): SSH `data`/EOF →
`Chunk::stdin` (decide the EOF translation — the zero-length `Chunk::stdin` (decide the EOF translation: the zero-length
stdin-chunk sentinel vs alktty's `ControlMessage::Eof` CTRL_IN — stdin-chunk sentinel vs alktty's `ControlMessage::Eof` CTRL_IN;
verify what alktty's ChunkReader/backend actually consumes and verify what alktty's ChunkReader/backend actually consumes and
record); stdout chunks → SSH `data`; stderr chunks → record); stdout chunks → SSH `data`; stderr chunks →
`extended_data(1)` (PTY mode merges upstream — the backend's `extended_data(1)` (PTY mode merges upstream, where the backend's
stderr stream is `None` there, so the translation is universal, no stderr stream is `None`, so the translation is universal with no
mode-switch); the exit chunk (`STREAM_CTRL_OUT`) → the derivation mode-switch); the exit chunk (`STREAM_CTRL_OUT`) → the derivation
contract. No unbounded buffering in either adapter (convention 9). contract. No unbounded buffering in either adapter (convention 9).
- **Control rows** — `window_change_request` → `ControlMessage:: - **Control rows**: `window_change_request` → `ControlMessage::
Resize` CTRL_IN; `signal` → `ControlMessage::Signal` (alktty's Resize` CTRL_IN; `signal` → `ControlMessage::Signal` (alktty's
uppercase-name vocabulary; name accepted verbatim — decide the uppercase-name vocabulary, with the name accepted verbatim; decide
decline shape for names alktty's backend semantics reject and the decline shape for names alktty's backend semantics reject and
record). Mechanics: the requests arrive on the packet loop after record). Mechanics: the requests arrive on the packet loop after
the serving task owns the wire — reach them into the spawned bridge the serving task owns the wire, so reach them into the spawned
through a per-channel control surface (bounded mpsc — decide + bridge through a per-channel control surface (a bounded mpsc; decide
record; a silent unbounded proxy is a bounded-resources bug). plus record; a silent unbounded proxy is a bounded-resources bug).
- **The exit derivation contract** (mapping.md rules 1–6, - **The exit derivation contract** (mapping.md rules 1–6,
normative): the alktty exit chunk (`{"type":"exit","code":i32}`, normative): the alktty exit chunk (`{"type":"exit","code":i32}`,
exit-chunk-is-last) is the sole fidelity input, never widened: exit-chunk-is-last) is the sole fidelity input, never widened:
`code ≥ 0` → `exit-status(N)`; negative `−N`, N ≥ 2 → `code ≥ 0` → `exit-status(N)`; negative `−N`, N ≥ 2 →
`exit-signal(name)` via the **server-side Unix signum→name table** `exit-signal(name)` via the **server-side Unix signum→name table**
(crate-local; not derived from alktty's `signal_from_name` inverse (crate-local; not derived from alktty's `signal_from_name` inverse
unless that is public and sound — decide the table's form and unless that is public and sound; decide the table's form and
record; `core_dumped` **never asserted**, the bool stays false); record; `core_dumped` **never asserted**, the bool stays false);
`−1` → `exit-status(255)`, never a signal claim (rule 3 — the `−1` → `exit-status(255)`, never a signal claim (rule 3, the
SIGHUP collision accepted symmetrically, per the mapping.md SIGHUP collision accepted symmetrically per the mapping.md
rationale); non-Unix adds no other negatives (rule 4); the PTY-path rationale); non-Unix adds no other negatives (rule 4); the PTY-path
rule-5 fidelity limit documented in the module docs (signal- rule-5 fidelity limit documented in the module docs (signal-
terminated PTY sessions arrive `exit-status(1)` — portable-pty's terminated PTY sessions arrive `exit-status(1)`, portable-pty's
exit surface, upstream); unmapped negatives → 255 (rule 6). Sent exit surface, upstream); unmapped negatives → 255 (rule 6). Sent
**before** close, from the spawned task. **before** close, from the spawned task.
- **`env_request` stays declined** (OQ-09 — unchanged from wave 2); - **`env_request` stays declined** (OQ-09, unchanged from wave 2);
`session_cwd`/`session_env` ride `NegotiateRequest` only. `session_cwd`/`session_env` ride `NegotiateRequest` only.
- **Contract rows through the harness, per build** — the wave-2 stub - **Contract rows through the harness, per build**, with the wave-2
rows (`pty_row_stub`/`shell_row_stub`/ stub rows (`pty_row_stub`/`shell_row_stub`/
`pipe_row_stub`) replaced; the decline matrix's unset-field rows — `pipe_row_stub`) replaced; the decline matrix's unset-field rows
feature on + handle unset → the rows decline; featured+set serve (feature on + handle unset → the rows decline; featured+set serves
through the alkssh-side **TtyBackend shim double** (alktty's through the alkssh-side **TtyBackend shim double**, since alktty's
`MockBackend`/`testing.rs` are `pub(crate)` + `cfg(test)` — never `MockBackend`/`testing.rs` are `pub(crate)` + `cfg(test)` and so
importable; alkssh hand-rolls the shim: a backend that records never importable; alkssh hand-rolls the shim, a backend that
`TtyParams`, emits scripted stdout/stderr/exit chunks and consumes records `TtyParams`, emits scripted stdout/stderr/exit chunks and
stdin/resizes/signals — per the `TtyHandle` field shapes). The consumes stdin/resizes/signals, per the `TtyHandle` field shapes). The
identity-landing item (the parked wave-2 note) asserted through the identity-landing item (the parked wave-2 note) asserted through the
row that observes the identity at `drive_session_pre_negotiated`. row that observes the identity at `drive_session_pre_negotiated`.
- **The real-tool smoke** — one OpenSSH pty session through the - **The real-tool smoke**: one OpenSSH pty session through the
harness against the shim (or against a real PTY via a dev-dep harness against the shim (or against a real PTY via a dev-dep
`alktty` `local` composition — decide which posture and record; the `alktty` `local` composition; decide which posture and record; the
real bash/`stty size` fidelity was POC stage D's, wave 5 re-lands it real bash/`stty size` fidelity was POC stage D's, wave 5 re-lands it
as e2e): the smoke owns the wire rows a plain rustsh-client row as e2e): the smoke owns the wire rows a plain rustsh-client row
can't pin (echo paths, `stty size`-grade fidelity are wave-5's can't pin (echo paths, `stty size`-grade fidelity are wave-5's
@@ -112,7 +112,7 @@ proved, re-landed as contract:
per-channel surface) per-channel surface)
- [x] The derivation contract rules 1–6 in code with the signum→name - [x] The derivation contract rules 1–6 in code with the signum→name
table; `−1`→255; `core_dumped` never; PTY-path rule-5 documented; table; `−1`→255; `core_dumped` never; PTY-path rule-5 documented;
exit-status before close from the spawned task — pinned by rows exit-status before close from the spawned task, pinned by rows
- [x] The decline matrix per build green through the harness (unset- - [x] The decline matrix per build green through the harness (unset-
field rows included); identity ride asserted; the `env_request` field rows included); identity ride asserted; the `env_request`
decline unchanged; skeleton stub rows migrated decline unchanged; skeleton stub rows migrated
@@ -123,16 +123,16 @@ proved, re-landed as contract:
## References ## References
- docs/architecture/mapping.md (the `session` channel-type row, - docs/architecture/mapping.md (the `session` channel-type row,
request-level rows, §Exit chunk → SSH exit reporting — rules 1–6) request-level rows, §Exit chunk → SSH exit reporting, rules 1–6)
- docs/architecture/decisions/006-mapping-bridge-and-dispatch.md - docs/architecture/decisions/006-mapping-bridge-and-dispatch.md
(decision 4 — spawn-and-return: status before close from the spawned (decision 4, spawn-and-return: status before close from the spawned
task), docs/architecture/decisions/003-feature-graph.md task), docs/architecture/decisions/003-feature-graph.md
- docs/architecture/auth.md §Server direction step 4 (the identity - docs/architecture/auth.md §Server direction step 4 (the identity
ride), §Dispositions (OQ-03) ride), §Dispositions (OQ-03)
- docs/research/poc-3-mapping-bakeoff.md §Stage D (the bridge shape, - docs/research/poc-3-mapping-bakeoff.md §Stage D (the bridge shape,
control rows, the signal/exit fidelity findings, §D.3 mechanics) control rows, the signal/exit fidelity findings, §D.3 mechanics)
- docs/research/reverse-runner.md (pipe-mode pid-only signal caveat — - docs/research/reverse-runner.md (the pipe-mode pid-only signal
the fidelity note) caveat, the fidelity note)
- alktty 0.8.1: `adapter.rs:234-241` (`drive_session_pre_negotiated`), - alktty 0.8.1: `adapter.rs:234-241` (`drive_session_pre_negotiated`),
`negotiation.rs` (`NegotiateRequest`/`TerminalParamsWire`), `negotiation.rs` (`NegotiateRequest`/`TerminalParamsWire`),
`wire.rs` (chunks, readers/writers), `control.rs` (`ControlMessage`), `wire.rs` (chunks, readers/writers), `control.rs` (`ControlMessage`),
@@ -143,31 +143,32 @@ proved, re-landed as contract:
### Decisions recorded (implementation, 2026-10-28) ### Decisions recorded (implementation, 2026-10-28)
- **The module** (`src/tty_map.rs`, feature `session`): the session - **The module** (`src/tty_map.rs`, feature `session`): the session
rows live in one module following the `git_map` pattern — the rows live in one module following the `git_map` pattern, covering
bridge, the locally-built negotiation, the derivation contract, and the bridge, the locally-built negotiation, the derivation contract,
and
the serving path. `serde_json` (the OQ-44 modes JSON + the exit the serving path. `serde_json` (the OQ-44 modes JSON + the exit
chunk/error-frame reads) and `bytes` (the chunk plane) joined as chunk/error-frame reads) and `bytes` (the chunk plane) joined as
session-gated optional deps at alktty's own pins. session-gated optional deps at alktty's own pins.
- **The negotiation** (locally built, never wire-derived — OQ-08): - **The negotiation** (locally built, never wire-derived; OQ-08):
`pty_request` args → `TerminalParamsWire` with SSH ttymodes → the `pty_request` args → `TerminalParamsWire` with SSH ttymodes → the
**reserved `{}` modes JSON** (alktty OQ-44's reservation, deferred **reserved `{}` modes JSON** (alktty OQ-44's reservation, deferred
there; the map is honest — nothing is carried). Shell argv rides the there; the map is honest and nothing is carried). Shell argv rides the
serve-config **verbatim**; exec rides `wrapper ++ [cmd]` (never serve-config **verbatim**; exec rides `wrapper ++ [cmd]` (never
naively split); cwd/env come from `session_cwd`/`session_env` only. naively split); cwd/env come from `session_cwd`/`session_env` only.
`backend_params` stays empty (no backend-specific selector fields on `backend_params` stays empty (no backend-specific selector fields on
this door; the seam selector is the `backend` string). this door; the seam selector is the `backend` string).
**Pipe-less shell decided: a `shell_request` without a preceding **Pipe-less shell decided: a `shell_request` without a preceding
pty SERVES pipe-mode (`tty = None`)** — vanilla sshd serves a shell pty SERVES pipe-mode (`tty = None`)**, because vanilla sshd serves
without a terminal too (`ssh -T` is legitimate vanilla). a shell without a terminal too (`ssh -T` is legitimate vanilla).
- **Unset-declines (OQ-08) — wire shape decided + recorded**: the - **Unset-declines (OQ-08), wire shape decided + recorded**: the
**serving-path shape (`channel_failure` then close, from a spawned **serving-path shape (`channel_failure` then close, from a spawned
task)** for `shell_argv`-unset (`shell-unconfigured`), task)** for `shell_argv`-unset (`shell-unconfigured`),
`exec_wrapper_argv`-unset (`wrapper-unconfigured`), and the `exec_wrapper_argv`-unset (`wrapper-unconfigured`), and the
`session_seam`-unset rows (`seam-unconfigured` — the git row's `session_seam`-unset rows (`seam-unconfigured`, the git row's
recorded shape); the pty row checks the seam first (a doomed recorded shape); the pty row checks the seam first (a doomed
allocation is declined before its params are stored — decline first, allocation is declined before its params are stored (decline first,
alloc never). Field checks precede the seam check. The unvanilla alloc never). Field checks precede the seam check. The unvanilla
guards keep the git row's shapes: `channel-not-held` → inline guards keep the git row's shapes: `channel-not-held` → inline
request-level failure; `no-identity` → the spawned serving-path request-level failure; `no-identity` → the spawned serving-path
@@ -177,7 +178,7 @@ proved, re-landed as contract:
- **The translation bridge** (the POC-3 §D.1 shape, two adapters - **The translation bridge** (the POC-3 §D.1 shape, two adapters
around a duplex pair): SSH `data` → `Chunk::stdin` through a bounded around a duplex pair): SSH `data` → `Chunk::stdin` through a bounded
mpsc (bound 64) feeding a framing task; SSH channel EOF → the mpsc (bound 64) feeding a framing task; SSH channel EOF → the
**zero-length stdin-chunk sentinel** — decided and verified against **zero-length stdin-chunk sentinel**, decided and verified against
alktty 0.8.1's `pump_client_to_backend`: a zero-length stdin chunk alktty 0.8.1's `pump_client_to_backend`: a zero-length stdin chunk
shuts the backend's stdin, identical in effect to shuts the backend's stdin, identical in effect to
`ControlMessage::Eof`, and rides the data path the SSH EOF already `ControlMessage::Eof`, and rides the data path the SSH EOF already
@@ -189,31 +190,32 @@ proved, re-landed as contract:
- **The negotiation-error frame is a serving-path decline class** - **The negotiation-error frame is a serving-path decline class**
(decided + recorded): alktty re-runs validation/allocation/ownership (decided + recorded): alktty re-runs validation/allocation/ownership
inline even on the pre-negotiated path and answers failures with a inline even on the pre-negotiated path and answers failures with a
length-prefixed error frame (first byte `0x00` — alktty's ADR-052 §5 length-prefixed error frame (first byte `0x00`, which is alktty's
disambiguation invariant). The bridge reads it via a 4-byte ADR-052 §5 disambiguation invariant). The bridge reads it via a 4-byte
first-window disambiguation and maps it to `negotiation-failed` + first-window disambiguation and maps it to `negotiation-failed` +
**`exit-status(255)` + close — the one place this door claims 255**: **`exit-status(255)` + close, the one place this door claims 255**:
the session never started, nothing ran, so 255 is the honest the session never started, nothing ran, so 255 is the honest
could-not-start semantic (contrast the git row, whose service runs could-not-start semantic (contrast the git row, whose service runs
and honestly reports `1`; `255` is never fabricated for a real and honestly reports `1`; `255` is never fabricated for a real
outcome). A bridge read ending **without** an exit chunk reports outcome). A bridge read ending **without** an exit chunk reports
nothing (sole-fidelity-input posture: absence is neither 0 nor 255) nothing (sole-fidelity-input posture: absence is neither 0 nor 255),
— plain close. and simply closes.
- **The control rows**: `window_change_request` → - **The control rows**: `window_change_request` →
`ControlMessage::Resize`, `signal` → `ControlMessage::Signal`, `ControlMessage::Resize`, `signal` → `ControlMessage::Signal`,
forwarded through the **per-channel `BridgeSurface`** (handler forwarded through the **per-channel `BridgeSurface`** (handler
state while the row serves): a bounded mpsc whose `try_send` is state while the row serves): a bounded mpsc whose `try_send` is
never awaited on the packet loop — a full/closed surface drops the never awaited on the packet loop, so a full/closed surface drops
control event (fire-and-forget rows carry no SSH reply to decline the control event (fire-and-forget rows carry no SSH reply to decline
with; bounded resources, convention 9). **Signal names are accepted with; bounded resources, convention 9). **Signal names are accepted
verbatim — no decline shape**: the backend owns the rejection verbatim, no decline shape**: the backend owns the rejection
semantics, nothing on the wire can answer. A pre-serve resize semantics, nothing on the wire can answer. A pre-serve resize
updates the stored pty params instead (the final size wins — updates the stored pty params instead (the final size wins,
vanilla sshd's ordering). matching vanilla sshd's ordering).
- **The derivation contract** (mapping.md rules 1–6, in code + unit - **The derivation contract** (mapping.md rules 1–6, in code + unit
rows + wire rows): `derive_exit` — `code ≥ 0` → `exit-status(N)`; rows + wire rows): `derive_exit`, where `code ≥ 0` →
`exit-status(N)`;
negative `−N`, N ≥ 2 → `exit-signal(name)` via the crate-local negative `−N`, N ≥ 2 → `exit-signal(name)` via the crate-local
[`SIGNAL_NAMES`] signum→name table (1–31, server-side work, [`SIGNAL_NAMES`] signum→name table (1–31, server-side work,
deliberately not alktty's `signal_from_name` inverse); `−1` → deliberately not alktty's `signal_from_name` inverse); `−1` →
@@ -223,22 +225,22 @@ proved, re-landed as contract:
`i32::MIN`'s checked-abs failure) → 255 (rule 6); the PTY-path `i32::MIN`'s checked-abs failure) → 255 (rule 6); the PTY-path
rule-5 fidelity limit documented in the module docs. Report sent rule-5 fidelity limit documented in the module docs. Report sent
**before** close from the spawned task; names ride russh's `Sig` **before** close from the spawned task; names ride russh's `Sig`
(known variants verbatim, `Sig::Custom` for the rest — RFC 4254 (known variants verbatim, `Sig::Custom` for the rest; RFC 4254
§6.10). §6.10).
- **Cancellation** (bounded resources + alktty's kill-on-drop): the - **Cancellation** (bounded resources + alktty's kill-on-drop): the
serving path watches two signals — the per-channel cancel (fired by serving path watches two signals: the per-channel cancel (fired by
`channel_close` via the surface's watch) and the connection `channel_close` via the surface's watch) and the connection
teardown (fired by the handler's `Drop`; the russh session ended teardown (fired by the handler's `Drop`; the russh session ended
however it ended). On either: the serving task **aborts the alktty however it ended). On either: the serving task **aborts the alktty
session task**, dropping its not-yet-resolved `exit_code` future — session task**, dropping its not-yet-resolved `exit_code` future,
alktty's ADR-056 kill-on-drop contract is the cancelled session's letting alktty's ADR-056 kill-on-drop contract clean up the
cleanup — then closes the channel and ends. Witnessed live by the cancelled session, then closes the channel and ends. Witnessed live by the
shim's kill log row. shim's kill log row.
- **The contract tests** (`tests/tty_mapping.rs`, session-gated; - **The contract tests** (`tests/tty_mapping.rs`, session-gated;
`tests/common/tty_doubles.rs`): the shim double (hand-rolled per `tests/common/tty_doubles.rs`): the shim double (hand-rolled per
the `TtyHandle` field shapes — alktty's `MockBackend` is the `TtyHandle` field shapes, because alktty's `MockBackend` is
`pub(crate)` and never importable) records TtyParams, consumes `pub(crate)` and never importable) records TtyParams, consumes
stdin/EOF/resizes/signals, emits scripted chunks and exit codes, stdin/EOF/resizes/signals, emits scripted chunks and exit codes,
and carries the kill-on-drop witness; the ownership witness double and carries the kill-on-drop witness; the ownership witness double
@@ -250,20 +252,21 @@ proved, re-landed as contract:
cap row fixed). `env_request` decline unchanged (OQ-09). cap row fixed). `env_request` decline unchanged (OQ-09).
- **The identity ride** (the parked wave-2 item, discharged): asserted - **The identity ride** (the parked wave-2 item, discharged): asserted
through alktty's ADR-050 ownership check — the shim's through alktty's ADR-050 ownership check, with the shim's
`resource_id` guarantees the check evaluates, and the ownership `resource_id` guaranteeing the check evaluates, and the ownership
double records the resolved identity; the deny shape also pins the double records the resolved identity; the deny shape also pins the
negotiation-error wire shape. negotiation-error wire shape.
- **Real-tool smoke posture (recorded)**: one OpenSSH `-tt` pty - **Real-tool smoke posture (recorded)**: one OpenSSH `-tt` pty
session (`ssh -tt … smoke-rider@127.0.0.1` with piped stdin) through session (`ssh -tt … smoke-rider@127.0.0.1` with piped stdin) through
a TCP-mounted adapter against a **real PTY via a dev-dep alktty a TCP-mounted adapter against a **real PTY via a dev-dep alktty
`local` composition** — the decided posture: a shim row cannot show `local` composition**, the decided posture: a shim row cannot show
what vanilla OpenSSH really sends, and POC-3 stage D proved exactly what vanilla OpenSSH really sends, and POC-3 stage D proved exactly
this composition. The dev-dep `features = ["local"]` unification this composition. The dev-dep `features = ["local"]` unification
applies to the test graph only (the published lib never enables it; applies to the test graph only (the published lib never enables it;
`src/` never names a local shape). Requires ssh + sh; absent `src/` never names a local shape). Requires ssh + sh; absent
tooling is skipped **with a printed reason** — never silently green. tooling is skipped **with a printed reason**, never silently
green.
The smoke pins the pty lifecycle (serve, stdin ride, clean exit → The smoke pins the pty lifecycle (serve, stdin ride, clean exit →
exit-status(0) before close); the `stty size`-grade fidelity matrix exit-status(0) before close); the `stty size`-grade fidelity matrix
is wave 5's. is wave 5's.
@@ -273,4 +276,4 @@ proved, re-landed as contract:
`cargo test --features tunnel`, `cargo test --all-features`; `cargo test --features tunnel`, `cargo test --all-features`;
`cargo clippy --all-targets -- -D warnings` ×5 (default / session / `cargo clippy --all-targets -- -D warnings` ×5 (default / session /
git / tunnel / all-features); `cargo fmt --check`; `cargo doc git / tunnel / all-features); `cargo fmt --check`; `cargo doc
--no-deps` (default + session + all-features) — all green. --no-deps` (default + session + all-features); all green.
+59 -55
View File
@@ -1,6 +1,6 @@
--- ---
id: w3-tunnel-direct id: w3-tunnel-direct
name: Wave 3 — tunnel leg 1 (the route-1 bridge — direct-tcpip + streamlocal) name: "Wave 3: tunnel leg 1 (the route-1 bridge, direct-tcpip + streamlocal)"
status: completed status: completed
depends_on: [w3-tty-session] depends_on: [w3-tty-session]
scope: moderate scope: moderate
@@ -11,71 +11,72 @@ level: implementation
## Description ## Description
Land the tunnel branch's proven leg (ADR-006 decision 1) — the Land the tunnel branch's proven leg (ADR-006 decision 1), placing the
adapter-side route-1 bridge for `direct-tcpip` and `direct-streamlocal` adapter-side route-1 bridge for `direct-tcpip` and `direct-streamlocal`
(POC-3 stage C's shape, re-landed as contract): (POC-3 stage C's shape, re-landed as contract):
- **The route-1 bridge** — the `tunnel_row_stub` replaced with the - **The route-1 bridge**, the `tunnel_row_stub` replaced with the
real flow: per accepted open, the target/substrate are constructed real flow: per accepted open, the target/substrate are constructed
from the SSH open arguments (`direct-tcpip`(host, port) → from the SSH open arguments (`direct-tcpip`(host, port) →
`SubstrateAddr::HostPort` + `Substrate::Tcp`; `direct-streamlocal` `SubstrateAddr::HostPort` + `Substrate::Tcp`; `direct-streamlocal`
(socket path) → `SubstrateAddr::Path` + `Substrate::Unix`) and (socket path) → `SubstrateAddr::Path` + `Substrate::Unix`) and
spawn the flow POC-3 proved: **`TunnelSession::open_direct(& spawn the flow POC-3 proved: **`TunnelSession::open_direct(&
channel_client, target, substrate)`** → **accept only after the channel_client, target, substrate)`** → **accept only after the
fabric open succeeds** (accept consumes the handle — the reject fabric open succeeds** (accept consumes the handle, and the
shape is only possible pre-accept) → on fabric failure the explicit reject shape is only possible pre-accept) → on fabric failure the explicit
`ConnectFailed` reject (the stub's reason survives as the real `ConnectFailed` reject (the stub's reason survives as the real
decline; `tunnel_seam_stub_decline` retires into it) → on success decline; `tunnel_seam_stub_decline` retires into it) → on success
`take_halves()` pumped against the taken channel's `take_halves()` pumped against the taken channel's
stream (`pump_bidi` — the alkcall primitive the POC recipe used) on stream (`pump_bidi`, the alkcall primitive the POC recipe used) on
the spawned task, per spawn-and-return. The open hook returns fast the spawned task, per spawn-and-return. The open hook returns fast
with the fabric open in flight. with the fabric open in flight.
- **The cap admission across the fabric window** — the wave-2 stub - **The cap admission across the fabric window**, where the wave-2 stub
declined without consuming a count; the real flow admits. Mechanics declined without consuming a count; the real flow admits. Mechanics
to get right (record): the admission is taken at the hook (the to get right (record): the admission is taken at the hook (the
packet-loop single-writer site — no race window against the cap packet-loop single-writer site, with no race window against the
check) and held across the fabric open; **a fabric failure must cap check) and held across the fabric open; **a fabric failure must
release the count inside the spawned task** (the release the count inside the spawned task** (the
`channel_close`-hook release point only fires for established `channel_close`-hook release point only fires for established
channels — a rejected open never established one), so a failed channels, and a rejected open never established one), so a failed
bridge cannot leak budget. Decide the release mechanism and bridge cannot leak budget. Decide the release mechanism and
record. record.
- **Identity propagation to the family fabric (auth.md §Server - **Identity propagation to the family fabric (auth.md §Server
direction step 4 — the visible-surface invariant's gate)**: the direction step 4, the visible-surface invariant's gate)**: the
resolved identity rides the channels connection so the fabric's resolved identity rides the channels connection so the fabric's
`AccessControl::check(peer_identity)` gates the mapped opens. The `AccessControl::check(peer_identity)` gates the mapped opens. The
parked wave-2 `auth_succeeded` item lands here: on auth completion parked wave-2 `auth_succeeded` item lands here: on auth completion
the handler propagates **once** via alkcall's `Connection:: the handler propagates **once** via alkcall's `Connection::
set_identity` — mechanics decided and recorded (the alkhub set_identity`, with mechanics decided and recorded (the alkhub
`hub_leg.rs` one-time-set precedent: `ChannelClient:: `hub_leg.rs` one-time-set precedent: `ChannelClient::
take_call_connection()` → `set_identity(identity)`; check the take_call_connection()` → `set_identity(identity)`; check the
`take` semantics against alkcall 0.10.1's docs — the relay note `take` semantics against alkcall 0.10.1's docs, since the relay
note
says the detach is wrong for hubs; verify a door's reading and says the detach is wrong for hubs; verify a door's reading and
hook placement, `auth_succeeded` vs the first seam call). hook placement, `auth_succeeded` vs the first seam call).
- **The runtime-Unix gate** (OQ-11 — universal compile, clean - **The runtime-Unix gate** (OQ-11: universal compile, clean
open-decline off-Unix): `direct-streamlocal` compiles wherever open-decline off-Unix): `direct-streamlocal` compiles wherever
`tunnel` compiles; off-Unix a clean open-decline **before any `tunnel` compiles; off-Unix a clean open-decline **before any
fabric call** (never a partial bridge); on-Unix the real bridge. fabric call** (never a partial bridge); on-Unix the real bridge.
- **Contract rows through the harness, per build** (the wave-2 gate's - **Contract rows through the harness, per build** (the wave-2 gate's
booked acceptor vocabulary note is the listen leg's — this task's booked acceptor vocabulary note is the listen leg's, so this
rows ride plain client opens): a git/session build with `tunnel` task's rows ride plain client opens): a git/session build with `tunnel`
off declines the tunnel opens (unfeatured, `AdministrativelyProhibited`); off declines the tunnel opens (unfeatured, `AdministrativelyProhibited`);
the featured rows: a configured `tunnel` handle (feature + `Arc< the featured rows: a configured `tunnel` handle (feature + `Arc<
ChannelClient>`) serves, an **absent handle declined with a clean ChannelClient>`) serves, an **absent handle declined with a clean
error even under the feature** (the unchanged unset-declines error even under the feature** (the unchanged unset-declines
posture — the wave-1 config note), the cap rows (cap-gated before posture, the wave-1 config note), the cap rows (cap-gated before
spawn — never a leaked count over a failed bridge: the release spawn, never a leaked count over a failed bridge, meaning the
mechanism pinning row), and the `ConnectFailed` row over a refusing release mechanism pinning row), and the `ConnectFailed` row over a refusing
fabric double. The **fabric double** is the POC-3 §C.4 in-process fabric double. The **fabric double** is the POC-3 §C.4 in-process
recipe as test composition (ChannelsAdapter + InstallChannelZero + recipe as test composition (ChannelsAdapter + InstallChannelZero +
the alktunnels openables + a hand-rolled dial/dial-fail seam) — the alktunnels openables + a hand-rolled dial/dial-fail seam),
hand-rolled in `tests/`, never a sibling-checkout path. hand-rolled in `tests/`, never a sibling-checkout path.
- **The optional dep lands here** (`alktunnels = "0.4.1"`, wired - **The optional dep lands here** (`alktunnels = "0.4.1"`, wired
`tunnel = ["dep:alktunnels"]` — first consuming code; crates.io pin `tunnel = ["dep:alktunnels"]` (first consuming code; the crates.io
re-verified before committing). pin re-verified before committing).
- **The real-tool smoke** — one `ssh -L` (+ curl) through the - **The real-tool smoke**: one `ssh -L` (+ curl) through the
harness against the fabric double (tooling posture decided like the harness against the fabric double (tooling posture decided like the
git task — required vs conditional-skip, recorded). git task, either required or conditional-skip, recorded).
## Acceptance Criteria ## Acceptance Criteria
@@ -84,8 +85,8 @@ adapter-side route-1 bridge for `direct-tcpip` and `direct-streamlocal`
explicit `ConnectFailed` reject, the stub retired; the open explicit `ConnectFailed` reject, the stub retired; the open
hook returns fast hook returns fast
- [x] The cap admission across the fabric window pinned (admit at the - [x] The cap admission across the fabric window pinned (admit at the
hook, release on fabric failure inside the spawned task — no hook, release on fabric failure inside the spawned task, with
leaked count, no double-admit) no leaked count and no double-admit)
- [x] The identity propagates to the channels connection once - [x] The identity propagates to the channels connection once
(`set_identity` mechanics + hook placement decided and (`set_identity` mechanics + hook placement decided and
recorded); the wave-2 `auth_succeeded` item discharged recorded); the wave-2 `auth_succeeded` item discharged
@@ -105,7 +106,7 @@ adapter-side route-1 bridge for `direct-tcpip` and `direct-streamlocal`
- docs/architecture/mapping.md (the `direct-tcpip` / `direct- - docs/architecture/mapping.md (the `direct-tcpip` / `direct-
streamlocal` channel-type rows; §the structural constraint) streamlocal` channel-type rows; §the structural constraint)
- docs/architecture/decisions/006-mapping-bridge-and-dispatch.md - docs/architecture/decisions/006-mapping-bridge-and-dispatch.md
(decision 1 — route 1; the reject shape; the wiring recipe pointer) (decision 1, route 1; the reject shape; the wiring recipe pointer)
- docs/architecture/decisions/003-feature-graph.md (the tunnel row, - docs/architecture/decisions/003-feature-graph.md (the tunnel row,
the OQ-11 runtime-Unix gate) the OQ-11 runtime-Unix gate)
- docs/architecture/auth.md §Server direction step 4 (set_identity, - docs/architecture/auth.md §Server direction step 4 (set_identity,
@@ -126,26 +127,26 @@ adapter-side route-1 bridge for `direct-tcpip` and `direct-streamlocal`
> the module docs (`src/tunnel_map.rs`, `src/channel_cap.rs`, > the module docs (`src/tunnel_map.rs`, `src/channel_cap.rs`,
> `src/handler.rs`) and are summarized here for the gate. > `src/handler.rs`) and are summarized here for the gate.
- **The cap release mechanism (the task's first decide-and-record)** — - **The cap release mechanism (the task's first decide-and-record)**:
`ChannelCap` became a `parking_lot::Mutex<usize>` shared as an `ChannelCap` became a `parking_lot::Mutex<usize>` shared as an
`Arc`: **admissions stay packet-loop-only** (every `admit` call site `Arc`: **admissions stay packet-loop-only** (every `admit` call site
is an open hook — the check-and-increment is one atomic section is an open hook, so the check-and-increment is one atomic section
against the shared writer), while **releases have exactly two against the shared writer), while **releases have exactly two
disjoint sites** — the `channel_close` hook for *established* disjoint sites**, the `channel_close` hook for *established*
channels, and *inside the spawned bridge task* on a fabric-open channels, and *inside the spawned bridge task* on a fabric-open
failure (the admitted-but-rejected shape: ADR-006 decision 1 rejects failure (the admitted-but-rejected shape: ADR-006 decision 1 rejects
the open, so no `CHANNEL_CLOSE` can ever cycle back, and the count the open, so no `CHANNEL_CLOSE` can ever cycle back, and the count
must be released where the failure is known — before the reject is must be released where the failure is known, before the reject is
answered, so the very next open observes the recovered budget). answered, so the very next open observes the recovered budget).
Both release sites saturate. Pinning rows: Both release sites saturate. Pinning rows:
`a_failed_bridge_releases_the_cap_count_before_the_next_open` `a_failed_bridge_releases_the_cap_count_before_the_next_open`
(failure-then-serve under `max_concurrent_channels: 1` — a leaked (failure-then-serve under `max_concurrent_channels: 1`, where a leaked
count would answer `AdministrativelyProhibited`) and count would answer `AdministrativelyProhibited`) and
`the_cap_refuses_beyond_the_ceiling_and_recovers_on_close` `the_cap_refuses_beyond_the_ceiling_and_recovers_on_close`
(refusal + close-hook recovery). (refusal + close-hook recovery).
- **`set_identity` mechanics (the task's second decide-and-record; the - **`set_identity` mechanics (the task's second decide-and-record; the
`w2-auth-bridge` parked item discharged)** — the alkhub `hub_leg.rs` `w2-auth-bridge` parked item discharged)**, the alkhub `hub_leg.rs`
one-time-set precedent is a **serving-side** set (the hub resolves one-time-set precedent is a **serving-side** set (the hub resolves
the peer identity and sets it on its accept-side channel-0 the peer identity and sets it on its accept-side channel-0
`Connection` inside the channel-0 install, before any dispatch). `Connection` inside the channel-0 install, before any dispatch).
@@ -154,45 +155,46 @@ adapter-side route-1 bridge for `direct-tcpip` and `direct-streamlocal`
`CallConnection::connection()` → `Connection::set_identity`) was `CallConnection::connection()` → `Connection::set_identity`) was
**checked and rejected for this door**, verified against alkcall **checked and rejected for this door**, verified against alkcall
0.10.1's docs: the take is a one-shot single-slot detach 0.10.1's docs: the take is a one-shot single-slot detach
("taking it detaches the client's own calling surface" — every later ("taking it detaches the client's own calling surface", so every
later
`call_open_op`, and with it every `TunnelSession::open_direct`, `call_open_op`, and with it every `TunnelSession::open_direct`,
errors "channel client closed"), and the slot is the **shared errors "channel client closed"), and the slot is the **shared
assembly handle** (`SshAdapter` hands one `Arc<ServeConfig>` to assembly handle** (`SshAdapter` hands one `Arc<ServeConfig>` to
every mounted session), so the first session's take would detach every mounted session), so the first session's take would detach
the open surface for every later session of the whole serving the open surface for every later session of the whole serving
direction — the relay note's "the detach is wrong for hubs" direction, and the relay note's "the detach is wrong for hubs"
applies a fortiori. The identity that alkcall's protocol consults applies a fortiori. The identity that alkcall's protocol consults
at the fabric's serving dispatch is the channels connection's at the fabric's serving dispatch is the channels connection's
**dial identity** (CF-005: set once at dial, propagated to channel **dial identity** (CF-005: set once at dial, propagated to channel
0 at `from_connection`) — identity crossing the alkcall wire is not 0 at `from_connection`), since identity crossing the alkcall wire
a protocol concept. Decision: the composition — which identity the is not a protocol concept. Decision: the composition, meaning which identity the
fabric leg dials with, against which the fabric's ACL gates fabric leg dials with, against which the fabric's ACL gates
alkssh's mapped opens — is **assembly policy** (the assembly alkssh's mapped opens, is **assembly policy** (the assembly
supplies both the injected `IdentityProvider` and the fabric leg); supplies both the injected `IdentityProvider` and the fabric leg);
alkssh structurally refuses anonymous serving (the `no-identity` alkssh structurally refuses anonymous serving (the `no-identity`
open guard, unreachable on the wire — russh authorizes before open guard, unreachable on the wire since russh authorizes before
channels exist — fail-closed like the git/tty rows) so no channels exist, fail-closed like the git/tty rows) so no
SSH-shaped exception to the visible-surface invariant exists, and SSH-shaped exception to the visible-surface invariant exists, and
the harness rows witness the composed gate end to end: the harness rows witness the composed gate end to end:
`the_fabric_acl_refuses_an_unscoped_channels_identity` (the `the_fabric_acl_refuses_an_unscoped_channels_identity` (the
fabric's `AccessControl::check` refusing a scopeless channels fabric's `AccessControl::check` refusing a scopeless channels
identity — the establisher never dialed) and identity, the establisher never dialed) and
`direct_tcpip_serves_through_the_route_1_bridge`'s identity-witness `direct_tcpip_serves_through_the_route_1_bridge`'s identity-witness
assertion (the establisher's per-call auth seeing the channels assertion (the establisher's per-call auth seeing the channels
identity — POC-3 §C.3). **Hook placement**: the identity store identity, POC-3 §C.3). **Hook placement**: the identity store
stays the `auth_publickey` ride-along in handler state; the stays the `auth_publickey` ride-along in handler state; the
tunnel rows' dispatch reads it; `auth_succeeded` stays tunnel rows' dispatch reads it; `auth_succeeded` stays
unimplemented (russh default) — nothing rides it that the store unimplemented (russh default), since nothing rides it that the
does not carry. **Upstream ask booked for the wave-3 gate**: a store does not carry. **Upstream ask booked for the wave-3 gate**: a
non-detaching post-construction identity seam on `ChannelClient` non-detaching post-construction identity seam on `ChannelClient`
(or per-session fabric legs) would let the door attach per-peer (or per-session fabric legs) would let the door attach per-peer
identities to a shared leg; nothing is silently absorbed. identities to a shared leg; nothing is silently absorbed.
- **Wire decline classes (recorded)** — two vanilla-observable classes - **Wire decline classes (recorded)**, two vanilla-observable
at the open: *policy refusals* → `AdministrativelyProhibited` classes at the open: *policy refusals* → `AdministrativelyProhibited`
(unfeatured, cap-exceeded, the `no-identity` guard) vs (unfeatured, cap-exceeded, the `no-identity` guard) vs
*cannot-bridge* → `ConnectFailed` *cannot-bridge* → `ConnectFailed`
([`dispatch::connect_failed_open_decline`] — the wave-2 stub's ([`dispatch::connect_failed_open_decline`], the wave-2 stub's
reason surviving as the real decline): the failed fabric open, the reason surviving as the real decline): the failed fabric open, the
absent fabric handle under the feature (`seam-unconfigured`, the absent fabric handle under the feature (`seam-unconfigured`, the
unset-declines posture), an impossible wire target unset-declines posture), an impossible wire target
@@ -201,30 +203,32 @@ adapter-side route-1 bridge for `direct-tcpip` and `direct-streamlocal`
are distinguishable in the event vocabulary, which gained are distinguishable in the event vocabulary, which gained
`invalid-target` and `unix-unavailable`. `invalid-target` and `unix-unavailable`.
- **Admission mechanics** — the pre-admit decline classes are checked - **Admission mechanics**, so the pre-admit decline classes are
checked
in order (unset handle → no-identity → invalid target), so every in order (unset handle → no-identity → invalid target), so every
pure-decline path consumes nothing; the admission is taken at the pure-decline path consumes nothing; the admission is taken at the
hook and held across the fabric open; the success release rides the hook and held across the fabric open; the success release rides the
established channel's close. `forwarded-tcpip` kept its established channel's close. `forwarded-tcpip` kept its
pre-admit `has_room` cap gate and stub — its `-R` data-leg flow is pre-admit `has_room` cap gate and stub, as its `-R` data-leg flow
`w3-tunnel-listen` material (the harness's russh client cannot is `w3-tunnel-listen` material (the harness's russh client cannot
initiate the type; its wire rows are acceptor vocabulary). initiate the type; its wire rows are acceptor vocabulary).
- **The fabric double** — the POC-3 §C.4 in-process recipe as test - **The fabric double**, the POC-3 §C.4 in-process recipe as test
composition (`wire_fabric` in `tests/tunnel_mapping.rs`): composition (`wire_fabric` in `tests/tunnel_mapping.rs`):
`ChannelsAdapter` + `InstallChannelZero` + `ChannelOperations` + `ChannelsAdapter` + `InstallChannelZero` + `ChannelOperations` +
`register_tunnel_direct_openable` + `Dispatcher::serve_single_stream` `register_tunnel_direct_openable` + `Dispatcher::serve_single_stream`
over a tokio duplex, the connect side the `Arc<ChannelClient>` the over a tokio duplex, the connect side the `Arc<ChannelClient>` the
serve-config holds. The hand-rolled dial/dial-fail seam is an serve-config holds. The hand-rolled dial/dial-fail seam is an
in-process map of duplex halves keyed by the establisher-rendered in-process map of duplex halves keyed by the establisher-rendered
target string (consume-once — the cap-recovery row reads the target string (consume-once, and the cap-recovery row reads the
consume-once dial as its release signal), with an atomics call consume-once dial as its release signal), with an atomics call
counter for the "never called" assertions (the ACL row and the counter for the "never called" assertions (the ACL row and the
OQ-11 row); the smoke's dial seam is a real-TCP closure (alktunnels' OQ-11 row); the smoke's dial seam is a real-TCP closure (alktunnels'
`local` feature stays off everywhere — the shipped crate and the `local` feature stays off everywhere, in both the shipped crate
tests). and the tests).
- **The real-tool smoke** — tooling posture decided like the git task - **The real-tool smoke**, with tooling posture decided like the git
task
(conditional skip with a printed reason when `ssh`/`curl` is (conditional skip with a printed reason when `ssh`/`curl` is
absent), and it ran green here: one `ssh -N -L` (OpenSSH client) absent), and it ran green here: one `ssh -N -L` (OpenSSH client)
through a TCP-mounted adapter → the route-1 bridge → the fabric through a TCP-mounted adapter → the route-1 bridge → the fabric
+44 -44
View File
@@ -1,6 +1,6 @@
--- ---
id: w3-tunnel-listen id: w3-tunnel-listen
name: Wave 3 — tunnel leg 2 (the -R listen flow + the forwarded-tcpip data leg) name: "Wave 3: tunnel leg 2 (the -R listen flow + the forwarded-tcpip data leg)"
status: completed status: completed
depends_on: [w3-tunnel-direct] depends_on: [w3-tunnel-direct]
scope: moderate scope: moderate
@@ -12,18 +12,18 @@ level: implementation
## Description ## Description
Land the `-R` leg's server direction (mapping.md §Global-request rows + Land the `-R` leg's server direction (mapping.md §Global-request rows +
the `forwarded-tcpip` channel-type row) — the one leg the POC only the `forwarded-tcpip` channel-type row), and it is the one leg the POC only
exercised as refusal ("only the refusal shape was exercised"), so this exercised as refusal ("only the refusal shape was exercised"), so this
task carries the wave-3 gate's biggest open mechanics. Shape per the task carries the wave-3 gate's biggest open mechanics. Shape per the
family door semantics (vanilla -R: the door's fabric listens, per family door semantics (vanilla -R: the door's fabric listens, per
arrival the data flows back to the vanilla client, which dials its own arrival the data flows back to the vanilla client, which dials its own
target): target):
- **The listen request rows** — `tcpip_forward` / `streamlocal_forward` - **The listen request rows**: `tcpip_forward` / `streamlocal_forward`
(and their cancel forms) go from wave-2's unconditional decline to (and their cancel forms) go from wave-2's unconditional decline to
the real flow under `tunnel` (+ the streamlocal runtime-Unix gate the real flow under `tunnel` (+ the streamlocal runtime-Unix gate
riding its serving flow, OQ-11): the listen is mapped **family-ward** riding its serving flow, OQ-11): the listen is mapped **family-ward**
through the alktunnels listen/forwarded shapes — the door owns through the alktunnels listen/forwarded shapes, and the door owns
nothing local. The **exact composition is decided here** against nothing local. The **exact composition is decided here** against
alktunnels 0.4.1's actual producer/consumer surfaces (`TunnelListener:: alktunnels 0.4.1's actual producer/consumer surfaces (`TunnelListener::
open`'s listen dial + reply fields; the minted listen token in open`'s listen dial + reply fields; the minted listen token in
@@ -36,52 +36,52 @@ target):
door-side acceptance of the fabric's inbound forwarded opens door-side acceptance of the fabric's inbound forwarded opens
(alkcall channel-manager openable registration on the door-side (alkcall channel-manager openable registration on the door-side
connection) or the serve-config's tunnel handle must grow a connection) or the serve-config's tunnel handle must grow a
registration-seam handle — **if the door-side handle shape must be registration-seam handle; **if the door-side handle shape must be
amended, amend it here** (the `w3-seam-config` shape, recorded as amended, amend it here** (the `w3-seam-config` shape, recorded as
this wave's second config course-correction). If the topology does this wave's second config course-correction). If the topology does
not close over the published alkcall 0.10.1 / alktunnels 0.4.1 not close over the published alkcall 0.10.1 / alktunnels 0.4.1
surfaces, **stop and book it** for the gate — never silently surfaces, **stop and book it** for the gate, never silently
forked, worked around in-family, or re-litigating ADR-006's forked, worked around in-family, or re-litigating ADR-006's
resolution. resolution.
- **The `forwarded-tcpip` data leg** (mapping.md's row — "hand the - **The `forwarded-tcpip` data leg** (mapping.md's row, "hand the
channel duplex to the handler"): per fabric arrival, the door opens channel duplex to the handler"): per fabric arrival, the door opens
a `forwarded-tcpip` channel **toward the vanilla client** with the a `forwarded-tcpip` channel **toward the vanilla client** with the
SSH message's fields mapped per the vanilla -R convention SSH message's fields mapped per the vanilla -R convention
(`host_to_connect`/`port` echoing the target args the original (`host_to_connect`/`port` echoing the target args the original
`tcpip_forward` request carried — recorded per pending listen; `tcpip_forward` request carried, recorded per pending listen;
originator fields carrying the arrival info — decide and record originator fields carrying the arrival info, decide and record
what `TunnelForwardedParams.peer` yields) → accept-or-reject per what `TunnelForwardedParams.peer` yields) → accept-or-reject per
the in-crate cap (the **acceptor vocabulary** the wave-2 gate the in-crate cap (the **acceptor vocabulary** the wave-2 gate
booked — the first shape of what wave 4 grows into the client-side booked, the first shape of what wave 4 grows into the client-side
acceptor registry) → pump the fabric `BiStream` ↔ the SSH channel acceptor registry) → pump the fabric `BiStream` ↔ the SSH channel
(`ForwardedHandler` ownership: the handler owns the pump per (`ForwardedHandler` ownership: the handler owns the pump per
alktunnels' shape; correlation-miss → teardown-at-birth, never a alktunnels' shape; correlation-miss → teardown-at-birth, never a
half-open SSH channel). half-open SSH channel).
- **Cancel forms + bounded bookkeeping** — a per-session listener map - **Cancel forms + bounded bookkeeping**, with a per-session listener map
(the bound-port handles) drives `cancel_tcpip_forward` / (the bound-port handles) drives `cancel_tcpip_forward` /
`cancel_streamlocal_forward` 1:1 into the family listener's `cancel_streamlocal_forward` 1:1 into the family listener's
teardown (`TunnelListener::close` posture); the listener count is a teardown (`TunnelListener::close` posture); the listener count is a
bounded resource (decide the bound relation to bounded resource (decide the bound relation to
`Limits::max_concurrent_channels` or a dedicated cap — record; no `Limits::max_concurrent_channels` or a dedicated cap, recorded; no
unbounded map). The bound-port out-param: `port = 0` → the unbounded map). The bound-port out-param: `port = 0` → the
actually-bound number returned through the `&mut u32` (the actually-bound number returned through the `&mut u32` (the
family-side listen reply's numbers; the mapping.md/client.md family-side listen reply's numbers; the mapping.md/client.md
lifecycle spec). lifecycle spec).
- **Identity**: the listen dial rides the propagated identity (the - **Identity**: the listen dial rides the propagated identity (the
fabric's `AccessControl` gates the listen + forwarded opens the fabric's `AccessControl` gates the listen + forwarded opens the
same as leg 1's — the visible-surface invariant across the whole same as leg 1's, giving the visible-surface invariant across the
listen flow). whole listen flow).
- **Contract rows through the harness, per build** — the wave-2 stub - **Contract rows through the harness, per build**, with the wave-2
rows replaced: featured + configured serve (the listen flow against stub rows replaced: featured + configured serve (the listen flow against
the fabric double); unfeatured/unset/streamlocal-off-Unix decline the fabric double); unfeatured/unset/streamlocal-off-Unix decline
cleanly (honest advertisement: a declined listen never left a half cleanly (honest advertisement: a declined listen never left a half
state); the cancel rows; the arrival → forwarded-tcpip → pump row; state); the cancel rows; the arrival → forwarded-tcpip → pump row;
cap rows at the accept point. The **harness grows the client-side cap rows at the accept point. The **harness grows the client-side
accept hook** (russh client accept hook** (russh client
`server_channel_open_forwarded_tcpip` — the server-initiated opens `server_channel_open_forwarded_tcpip`, the server-initiated opens
the contract rows observe; the wave-2 note said exactly this). the contract rows observe; the wave-2 note said exactly this).
- **The real-tool smoke** posture decided + recorded (a real `ssh -R` - **The real-tool smoke** posture decided + recorded (a real `ssh -R`
through the harness's TCP loopback if tooling permits — else the through the harness's TCP loopback if tooling permits; else the
harness rows carry wave 3 and the real-tool `-R` smoke is named harness rows carry wave 3 and the real-tool `-R` smoke is named
wave-5 e2e territory explicitly; never silently dropped). wave-5 e2e territory explicitly; never silently dropped).
@@ -93,7 +93,7 @@ target):
- [x] The family-ward listen composition decided against alktunnels - [x] The family-ward listen composition decided against alktunnels
0.4.1's actual surfaces and recorded; the door-side inbound-open 0.4.1's actual surfaces and recorded; the door-side inbound-open
mechanics closed (or a serve-config amendment landed + recorded; mechanics closed (or a serve-config amendment landed + recorded;
or stopped-and-booked for the gate — nothing silent) or stopped-and-booked for the gate, nothing silent)
- [x] The `forwarded-tcpip` data leg serves per the handler shape - [x] The `forwarded-tcpip` data leg serves per the handler shape
(target-arg echo per pending listen, cap-gated acceptor, the (target-arg echo per pending listen, cap-gated acceptor, the
pump, teardown-at-birth on correlation-miss) pump, teardown-at-birth on correlation-miss)
@@ -112,15 +112,15 @@ target):
- docs/architecture/mapping.md §Channel-type rows (`forwarded-tcpip` - docs/architecture/mapping.md §Channel-type rows (`forwarded-tcpip`
row), §Global-request rows (the `-R` listen flow, the POC-3 note) row), §Global-request rows (the `-R` listen flow, the POC-3 note)
- docs/architecture/client.md §Op rows (the `-R` leg, the acceptor - docs/architecture/client.md §Op rows (the `-R` leg, the acceptor
framing — the client mirror this leg shares vocabulary with), framing, the client mirror this leg shares vocabulary with),
§Lifecycle primitives (the bound-port spec) §Lifecycle primitives (the bound-port spec)
- docs/architecture/decisions/006-mapping-bridge-and-dispatch.md - docs/architecture/decisions/006-mapping-bridge-and-dispatch.md
(decision 1 — the fabric wiring recipe), docs/architecture/ (decision 1, the fabric wiring recipe), docs/architecture/
decisions/003-feature-graph.md (the tunnel row + §4 the decisions/003-feature-graph.md (the tunnel row + §4 the
client-side acceptor symmetry) client-side acceptor symmetry)
- docs/research/poc-3-mapping-bakeoff.md §Stage C (the recipe; - docs/research/poc-3-mapping-bakeoff.md §Stage C (the recipe;
§C.2's refusal shape this task goes past), OQ seed 4 §C.2's refusal shape this task goes past), OQ seed 4
- docs/research/reverse-runner.md (the `-R` row — the client-side - docs/research/reverse-runner.md (the `-R` row, the client-side
mirror of the same wire vocabulary) mirror of the same wire vocabulary)
- alktunnels 0.4.1: `producer.rs:718-772` (`ForwardedOpener`, - alktunnels 0.4.1: `producer.rs:718-772` (`ForwardedOpener`,
`make_bindfirst_accept_handler`), `producer.rs:982-1082` `make_bindfirst_accept_handler`), `producer.rs:982-1082`
@@ -142,7 +142,7 @@ smoke. 18 server-direction rows + 2 real-tool smokes green in
- **The composition decisions (recorded in `src/tunnel_map.rs`'s - **The composition decisions (recorded in `src/tunnel_map.rs`'s
module doc + `mapping.md` §Global-request rows):** module doc + `mapping.md` §Global-request rows):**
- *Who dials `tunnel/forwarded`*: the fabric (not the door) — - *Who dials `tunnel/forwarded`*: the fabric (not the door);
alktunnels' bind-first accept dispatcher alktunnels' bind-first accept dispatcher
(`make_bindfirst_accept_handler` + the `ForwardedOpener` closure) (`make_bindfirst_accept_handler` + the `ForwardedOpener` closure)
dials the op on the channel-0 call plane toward the listen opener dials the op on the channel-0 call plane toward the listen opener
@@ -151,10 +151,10 @@ smoke. 18 server-direction rows + 2 real-tool smokes green in
directions collision-free: the door allocates odd as the connect directions collision-free: the door allocates odd as the connect
side, the fabric even as the accept side). side, the fabric even as the accept side).
- *The door-side inbound-open mechanics*: closed over the published - *The door-side inbound-open mechanics*: closed over the published
surfaces — the `Arc<ChannelClient>` alone cannot carry them (a surfaces, as the `Arc<ChannelClient>` alone cannot carry them (a
client serves inbound ops on its channel-0 loop only when built client serves inbound ops on its channel-0 loop only when built
with `from_connection_with_serving` against an `OperationRegistry` with `from_connection_with_serving` against an `OperationRegistry`
the assembly owns; no post-construction way in) — so the the assembly owns; no post-construction way in), so the
serve-config's tunnel handle gained `tunnel_listen_seam` serve-config's tunnel handle gained `tunnel_listen_seam`
(`TunnelListenSeam`, the wave-3 seam-config's second (`TunnelListenSeam`, the wave-3 seam-config's second
course-correction): the same `Arc<OperationRegistry>` the assembly course-correction): the same `Arc<OperationRegistry>` the assembly
@@ -162,7 +162,7 @@ smoke. 18 server-direction rows + 2 real-tool smokes green in
forwarded openable itself (`register_tunnel_forwarded_openable` forwarded openable itself (`register_tunnel_forwarded_openable`
over a `ChannelCore` wrapping a clone of the held client's over a `ChannelCore` wrapping a clone of the held client's
manager, alkcall's secure default per-identity policy) at manager, alkcall's secure default per-identity policy) at
`SshAdapter::new` — a registration failure is a failed `SshAdapter::new`, where a registration failure is a failed
construction (`AdapterError::ListenRegistration`), never a construction (`AdapterError::ListenRegistration`), never a
half-configured adapter. A scratch probe (published crates only, half-configured adapter. A scratch probe (published crates only,
no `alktunnels/local`) proved the topology end to end before any no `alktunnels/local`) proved the topology end to end before any
@@ -173,10 +173,10 @@ smoke. 18 server-direction rows + 2 real-tool smokes green in
- *The resource-naming convention (the flow's one open surface)*: - *The resource-naming convention (the flow's one open surface)*:
the vanilla wire bind endpoint rides family-ward as the resource the vanilla wire bind endpoint rides family-ward as the resource
string verbatim (`"localhost:9200"` / `"0.0.0.0:9200"` / the string verbatim (`"localhost:9200"` / `"0.0.0.0:9200"` / the
socket path) — the OpenSSH client normalizes the no-address case socket path); the OpenSSH client normalizes the no-address case
to `"localhost"` before sending (sshd `channel_rfwd_bind_host` to `"localhost"` before sending (sshd `channel_rfwd_bind_host`
parity), the door passes through, and the fabric's parity), the door passes through, and the fabric's
establisher/bind closure (assembly policy — the dynamic-address establisher/bind closure (assembly policy, the dynamic-address
producer opt-in the door's convention fronts) treats it as the producer opt-in the door's convention fronts) treats it as the
bind endpoint. ADR-001's "resource is not an address" discipline bind endpoint. ADR-001's "resource is not an address" discipline
stays a producer-side naming rule; this is a recorded door-to- stays a producer-side naming rule; this is a recorded door-to-
@@ -185,34 +185,34 @@ smoke. 18 server-direction rows + 2 real-tool smokes green in
as refusal): per arrival → token correlation through the shared as refusal): per arrival → token correlation through the shared
`ListenRouter` (one route serves unboundedly many arrivals; `ListenRouter` (one route serves unboundedly many arrivals;
removed only at the cancel forms/session teardown) → the in-crate removed only at the cancel forms/session teardown) → the in-crate
per-session channel cap admits at the accept surface — the acceptor per-session channel cap admits at the accept surface, the acceptor
vocabulary's first shape, the wave-2 gate's booked item now vocabulary's first shape, the wave-2 gate's booked item now
discharged (never-established arrival paths release inside the discharged (never-established arrival paths release inside the
arrival pump; established closes release through the packet loop's arrival pump; established closes release through the packet loop's
`channel_close` as ever) → `Handle::channel_open_forwarded_tcpip` `channel_close` as ever) → `Handle::channel_open_forwarded_tcpip`
echoing the listen request's own args: the wire address string echoing the listen request's own args: the wire address string
verbatim + the actually-bound port (sshd `c->path` + verbatim + the actually-bound port (sshd `c->path` +
`get_local_port` parity — the OpenSSH client matches that exact `get_local_port` parity, since the OpenSSH client matches that
string + port and dials its own target), the exact string + port and dials its own target), the
`TunnelForwardedParams.peer` arrival mapped to the originator `TunnelForwardedParams.peer` arrival mapped to the originator
fields (HostPort → address/port; Path → path + 0, fields (HostPort → address/port; Path → path + 0,
degenerate-but-typed) → `pump_bidi(ssh, fabric)` (half-close degenerate-but-typed) → `pump_bidi(ssh, fabric)` (half-close
semantics: EOF-shutdown in the completed direction; the vanilla semantics: EOF-shutdown in the completed direction; the vanilla
client closes its half when its target socket dies — the row's client closes its half when its target socket dies (the row's
stand-in — and the cap release follows through the normal close). stand-in), and the cap release follows through the normal close).
Correlation-miss → teardown-at-birth (drop the fabric half; the Correlation-miss → teardown-at-birth (drop the fabric half; the
open op's channel reaps — never a half-open SSH channel). Unix open op's channel reaps, never a half-open SSH channel). Unix
arrivals (`streamlocal_forward` mints) open arrivals (`streamlocal_forward` mints) open
`forwarded-streamlocal@openssh.com` with the path echoed (russh's `forwarded-streamlocal@openssh.com` with the path echoed (russh's
server `Handle` carries that channel-open at this pin; the client server `Handle` carries that channel-open at this pin; the client
hook exists) — the "no russh hook" concern in the wave-2 stub notes hook exists); the "no russh hook" concern in the wave-2 stub notes
was about the door's *inbound* handlers of that type, not the was about the door's *inbound* handlers of that type, not the
server-initiated direction. server-initiated direction.
- **Bounded bookkeeping** (convention 9): the per-session listener - **Bounded bookkeeping** (convention 9): the per-session listener
map is packet-loop-only (like the `channels`/`ptys` stores); its map is packet-loop-only (like the `channels`/`ptys` stores); its
bound is a dedicated counter against the same bound is a dedicated counter against the same
`max_concurrent_channels` ceiling — a listen is at least one future `max_concurrent_channels` ceiling, since a listen is at least one
channel's potential; the listener/channels mix stays unconstrained future channel's potential; the listener/channels mix stays unconstrained
while both stay bounded (no unbound listener map). The route map is while both stay bounded (no unbound listener map). The route map is
bounded by construction: entries minted exactly at accepted listens, bounded by construction: entries minted exactly at accepted listens,
removed 1:1 at the cancels and at the owning session's teardown (the removed 1:1 at the cancels and at the owning session's teardown (the
@@ -220,8 +220,8 @@ smoke. 18 server-direction rows + 2 real-tool smokes green in
failed open → teardown-at-birth. `cancel` of an unknown failed open → teardown-at-birth. `cancel` of an unknown
(endpoint, bound-port) pair answers `false` (sshd (endpoint, bound-port) pair answers `false` (sshd
cancel-of-unknown parity). Admissions have two sites and releases cancel-of-unknown parity). Admissions have two sites and releases
three since this task — the cap's recorded single-writer reasoning three since this task, and the cap's recorded single-writer
updated in `channel_cap.rs` (mutex-guarded, saturating, both). reasoning updated in `channel_cap.rs` (mutex-guarded, saturating, both).
- **The real-tool smoke**: implemented and green here (tooling - **The real-tool smoke**: implemented and green here (tooling
check-gated like the -L smoke's posture): a real `ssh -R check-gated like the -L smoke's posture): a real `ssh -R
127.0.0.1:PORT:HTTP` (OpenSSH client) through the TCP-mounted 127.0.0.1:PORT:HTTP` (OpenSSH client) through the TCP-mounted
@@ -231,8 +231,8 @@ smoke. 18 server-direction rows + 2 real-tool smokes green in
- **The decline matrix** per build, all through the harness with the - **The decline matrix** per build, all through the harness with the
client-side accept hook in place (the harness handler's client-side accept hook in place (the harness handler's
`server_channel_open_forwarded_tcpip` / `server_channel_open_forwarded_tcpip` /
`server_channel_open_forwarded_streamlocal`, acceptor-driven — no `server_channel_open_forwarded_streamlocal`, acceptor-driven, so
acceptor → clean decline, the family's client-side contract): the no acceptor means a clean decline, the family's client-side contract): the
wave-2 stub listen rows migrated (`tcpip_forward_decline_emits_*` wave-2 stub listen rows migrated (`tcpip_forward_decline_emits_*`
split per build: `unfeatured` vs `seam-unconfigured`); the split per build: `unfeatured` vs `seam-unconfigured`); the
featured + configured rows serve/arrive/cancel (incl. the port-0 featured + configured rows serve/arrive/cancel (incl. the port-0
@@ -243,11 +243,11 @@ smoke. 18 server-direction rows + 2 real-tool smokes green in
rows (the fabric-scope gate on the listen dial, the door-side ACL rows (the fabric-scope gate on the listen dial, the door-side ACL
gate on arrivals); the OQ-11 off-Unix rows; the correlation-miss gate on arrivals); the OQ-11 off-Unix rows; the correlation-miss
row; the unvanilla inbound `forwarded-tcpip` open declined row; the unvanilla inbound `forwarded-tcpip` open declined
(event-level contract — russh's client has no arbitrary-type open (event-level contract, as russh's client has no arbitrary-type
to drive that row on the wire). open to drive that row on the wire).
- **`tests/common` grew**: the forwarding acceptor - **`tests/common` grew**: the forwarding acceptor
(`ForwardedAcceptor` + `ForwardedOpen` + `drive_with_acceptor`) (`ForwardedAcceptor` + `ForwardedOpen` + `drive_with_acceptor`)
and a minimal hand-rolled tracing subscriber (`trace_global` — the and a minimal hand-rolled tracing subscriber (`trace_global`, the
family posture: no `tracing-subscriber` dependency; opted in per family posture: no `tracing-subscriber` dependency; opted in per
row) as the leg-2 rows' failure-triage vehicle. row) as the leg-2 rows' failure-triage vehicle.
- **Verification (all green)**: `cargo test` across default / - **Verification (all green)**: `cargo test` across default /
@@ -1,6 +1,6 @@
--- ---
id: w4-channel-not-held-rows id: w4-channel-not-held-rows
name: Wave 4 — channel-not-held guard wire rows (wave-3 gate booked item 2) name: "Wave 4: channel-not-held guard wire rows (wave-3 gate booked item 2)"
status: completed status: completed
depends_on: [w4-git-exit-close] depends_on: [w4-git-exit-close]
scope: narrow scope: narrow
@@ -14,25 +14,27 @@ level: implementation
Close the wave-3 gate's booked finding item 2 Close the wave-3 gate's booked finding item 2
(`review-wave-3` §Booked findings): the **`channel-not-held` guard (`review-wave-3` §Booked findings): the **`channel-not-held` guard
lacks wire test rows**. The guard is a documented decline class on lacks wire test rows**. The guard is a documented decline class on
all three rows (git / tty pipe / shell) — a second exec (or request) all three rows (git / tty pipe / shell): a second exec (or request)
on an already-served channel — constructible on the wire, but no row on an already-served channel, constructible on the wire, but no row
asserts it in any build. `mapping.md`'s every-row-tested-in-every-build asserts it in any build. `mapping.md`'s every-row-tested-in-every-build
convention names it contract territory; the fail-closed invariant is convention names it contract territory; the fail-closed invariant is
contract on both sides of every feature (AGENTS.md convention 5). contract on both sides of every feature (AGENTS.md convention 5).
**Test-only task** — no production code change expected (if landing **Test-only task**, so no production code change is expected (if
landing
the rows exposes a guard gap, stop and book it for the gate rather the rows exposes a guard gap, stop and book it for the gate rather
than silently widening scope): than silently widening scope):
- Wire rows through the harness, **per build** (the per-build - Wire rows through the harness, **per build** (the per-build
cfg-pinned module convention): a second exec on a served git cfg-pinned module convention): a second exec on a served git
channel → the channel-not-held decline; the same on the tty pipe channel → the channel-not-held decline; the same on the tty pipe
row and the shell row — the decline class asserted in every build row and the shell row, with the decline class asserted in every
build
that carries the row (featured builds carry the serving rows; the that carries the row (featured builds carry the serving rows; the
decline-class rows ride the same per-build matrix the dispatch decline-class rows ride the same per-build matrix the dispatch
tests use). tests use).
- The rows observe the decline's wire shape + the tracing event - The rows observe the decline's wire shape + the tracing event
vocabulary (the `w3-decline-tracing` class/reason fields — the vocabulary (the `w3-decline-tracing` class/reason fields, and the
rows double as the vocabulary's coverage for this class). rows double as the vocabulary's coverage for this class).
- Depends on `w4-git-exit-close` only to keep `tests/git_mapping.rs` - Depends on `w4-git-exit-close` only to keep `tests/git_mapping.rs`
single-writer (the hardening lands first; these rows land into the single-writer (the hardening lands first; these rows land into the
@@ -43,7 +45,7 @@ than silently widening scope):
- [x] Wire rows for the channel-not-held decline class on all three - [x] Wire rows for the channel-not-held decline class on all three
rows (git / tty pipe / shell), per build, through the harness rows (git / tty pipe / shell), per build, through the harness
- [x] The rows assert the wire shape + the tracing event class/reason - [x] The rows assert the wire shape + the tracing event class/reason
- [x] No production code change (no guard gap exposed — nothing to - [x] No production code change (no guard gap exposed, so nothing to
book; the guard's shape held on the wire in every featured book; the guard's shape held on the wire in every featured
build) build)
- [x] `cargo test` (+ per-feature + `--all-features`), clippy ×2 - [x] `cargo test` (+ per-feature + `--all-features`), clippy ×2
@@ -52,7 +54,7 @@ than silently widening scope):
## References ## References
- tasks/implementation/review-wave-3.md §Booked findings item 2 - tasks/implementation/review-wave-3.md §Booked findings item 2
- docs/architecture/mapping.md (the request-level table — the - docs/architecture/mapping.md (the request-level table, the
channel-not-held class; the every-row-tested convention) channel-not-held class; the every-row-tested convention)
- tests/git_mapping.rs, tests/tty_mapping.rs, tests/dispatch_core.rs - tests/git_mapping.rs, tests/tty_mapping.rs, tests/dispatch_core.rs
(the per-build matrix convention the rows join), src/observe.rs (the per-build matrix convention the rows join), src/observe.rs
@@ -60,39 +62,40 @@ than silently widening scope):
## Notes ## Notes
- **Test-only, as planned** — no production change; no guard gap - **Test-only, as planned**, hence no production change; no guard
gap
surfaced (the landing verified the guard's documented shape on the surfaced (the landing verified the guard's documented shape on the
wire in every build that carries each row). wire in every build that carries each row).
- **Rows landed** (one per served row, each with the inline-decline - **Rows landed** (one per served row, each with the inline-decline
wire shape — `channel_failure` while the first serving path rides wire shape (`channel_failure` while the first serving path rides
on — plus the channel-stays-usable witness and the tracing event's on), plus the channel-stays-usable witness and the tracing event's
exact request-decline vocabulary `src/observe.rs` carries): exact request-decline vocabulary `src/observe.rs` carries):
- `tests/git_mapping.rs` (cfg `git`): a second exec on a served git - `tests/git_mapping.rs` (cfg `git`): a second exec on a served git
channel — the decline event carries `exec_route = "git"`, and the channel, whose decline event carries `exec_route = "git"`, and the
declined re-exec never spawns a second serving path (the doubles' declined re-exec never spawns a second serving path (the doubles'
resolve count stays at one). Race-free by construction: the resolve count stays at one). Race-free by construction: the
advertisement drains first and the serve parks at the doubles' advertisement drains first and the serve parks at the doubles'
gated `list_refs` behind it, so the wire is quiescent for the gated `list_refs` behind it, so the wire is quiescent for the
decline. decline.
- `tests/tty_mapping.rs` (cfg `session`), two rows: - `tests/tty_mapping.rs` (cfg `session`), two rows:
the pipe row (second exec on a served pipe channel — event the pipe row (second exec on a served pipe channel, with the event
carries `exec_route = "pipe"`) and the shell row (second shell carrying `exec_route = "pipe"`) and the shell row (second shell
request on a served shell channel — the event carries **no** request on a served shell channel, whose event carries **no**
`exec_route` field, pinning the absent-field shape on the wire). `exec_route` field, pinning the absent-field shape on the wire).
The exit-`None` shim script keeps the serve live and the wire The exit-`None` shim script keeps the serve live and the wire
quiescent. quiescent.
- **The per-build matrix**: the rows join the cfg-pinned convention — - **The per-build matrix**: the rows join the cfg-pinned convention,
git-build rows run in every build carrying `git`, session rows in git-build rows run in every build carrying `git`, session rows in
every build carrying `session` (verified across default / `git` / every build carrying `session` (verified across default / `git` /
`session` / `session,git` / `tunnel` / `sftp` / `--all-features`). `session` / `session,git` / `tunnel` / `sftp` / `--all-features`).
- **Shared capture helper**: `tests/common/event_capture.rs` (new) — - **Shared capture helper**: `tests/common/event_capture.rs` (new) with
the observe-suite's recorded hand-rolled global-subscriber the observe-suite's recorded hand-rolled global-subscriber
convention shared across the mapping binaries; `await_event` convention shared across the mapping binaries; `await_event`
field-matching (parallel rows never order against each other) and field-matching (parallel rows never order against each other) and
the exact-field-set assertion (`assert_request_decline`) — the the exact-field-set assertion (`assert_request_decline`), and the
rows double as this class's vocabulary coverage, including the rows double as this class's vocabulary coverage, including the
`debug` level and the build self-description. `debug` level and the build self-description.
- **No `dispatch_core.rs` rows added** — that binary builds no seam, - **No `dispatch_core.rs` rows added**, because that binary builds no seam,
so its git row declines at the config gate *before* the store-take; so its git row declines at the config gate *before* the store-take;
the guard is unreachable there (the reference names the per-build the guard is unreachable there (the reference names the per-build
convention the rows join, which the mapping binaries carry). convention the rows join, which the mapping binaries carry).
@@ -102,12 +105,13 @@ than silently widening scope):
The channel-not-held guard's wire rows landed on all three served The channel-not-held guard's wire rows landed on all three served
rows: `second_exec_on_a_served_git_channel_declines_channel_not_held` rows: `second_exec_on_a_served_git_channel_declines_channel_not_held`
(`tests/git_mapping.rs`), and the pipe/shell pair (`tests/git_mapping.rs`), and the pipe/shell pair
(`tests/tty_mapping.rs`) — each pinning the inline decline shape (`tests/tty_mapping.rs`), each pinning the inline decline shape
(`channel_failure`, no close, the first serve rides on, the channel (`channel_failure`, no close, the first serve rides on, the channel
stays usable, no second serving path) plus the decline event's exact stays usable, no second serving path) plus the decline event's exact
vocabulary (class `request`, reason `channel-not-held`, route vocabulary (class `request`, reason `channel-not-held`, route
`git`/`pipe`/absent, `debug` level, self-describing build). No `git`/`pipe`/absent, `debug` level, self-describing build). No
production change was needed — the guard held its documented shape in production change was needed, and the guard held its documented
shape in
every featured build. `tests/common/event_capture.rs` shares the every featured build. `tests/common/event_capture.rs` shares the
observe-suite's capture convention across the mapping binaries. observe-suite's capture convention across the mapping binaries.
Verified: full suite + per-feature builds (default, `git`, `session`, Verified: full suite + per-feature builds (default, `git`, `session`,
+49 -45
View File
@@ -1,6 +1,6 @@
--- ---
id: w4-dial id: w4-dial
name: Wave 4 — the client dial surface (russh client stack + the connection handle) name: "Wave 4: the client dial surface (russh client stack + the connection handle)"
status: completed status: completed
depends_on: [review-wave-3] depends_on: [review-wave-3]
scope: moderate scope: moderate
@@ -11,49 +11,52 @@ level: implementation
## Description ## Description
Land the client direction's substrate — the door outward Land the client direction's substrate, the door facing outward
(`client.md` §The dial surface): the russh client stack every wave-4 (`client.md` §The dial surface): the russh client stack every wave-4
op row composes into, mirroring `w2-server-mount`'s role for the op row composes into, mirroring `w2-server-mount`'s role for the
server direction. Everything later in the wave rides the authenticated server direction. Everything later in the wave rides the authenticated
connection handle this task lands. connection handle this task lands.
- **The dial surface** — `connect`-shaped entry taking the wave-1 - **The dial surface**: a `connect`-shaped entry taking the wave-1
[`DialConfig`](the address, the key source, the host-key policy, the [`DialConfig`](the address, the key source, the host-key policy, the
`Limits`): the russh client `Config` built from the bounded knobs `Limits`): the russh client `Config` built from the bounded knobs
(keepalive 15 s / 3 default posture per the reverse-runner evidence — (keepalive 15 s / 3 default posture per the reverse-runner evidence
`client.md` §Correctness facts; inactivity; window/packet knobs per and `client.md` §Correctness facts; inactivity; window/packet knobs per
`overview.md`'s table), the dial target as `DialConfig.address` `overview.md`'s table), the dial target as `DialConfig.address`
carries it. If the dial surface needs a `DialConfig` amendment, carries it. If the dial surface needs a `DialConfig` amendment,
land + record it here (the `w3-seam-config` course-correction land + record it here (the `w3-seam-config` course-correction
pattern) — the wave-1 shape is expected to hold; decide and record. pattern), while the wave-1 shape is expected to hold; decide and
- **Key source decrypt-at-dial** (ADR-005) — the [`KeySource`] load record.
- **Key source decrypt-at-dial** (ADR-005), with the [`KeySource`]
load
happens at dial time; locked / absent / decryption-failure / happens at dial time; locked / absent / decryption-failure /
unknown-`key_version` are all **no-dial outcomes** with their unknown-`key_version` are all **no-dial outcomes** with their
distinct `KeySourceError` variants surfaced (never env vars, never distinct `KeySourceError` variants surfaced (never env vars, never
plaintext at rest; the adapter-side `Zeroizing` wrap per OQ-07 plaintext at rest; the adapter-side `Zeroizing` wrap per OQ-07
already landed in wave 1 — consumed here). already landed in wave 1, consumed here).
- **Host-key policy at `check_server_key`** (OQ-05) — the wave-1 - **Host-key policy at `check_server_key`** (OQ-05), the wave-1
[`HostKeyPolicy`] seam consumed at russh's client hook (the verdict [`HostKeyPolicy`] seam consumed at russh's client hook (the verdict
type was pinned against this hook shape in `w1-host-key-policy`): type was pinned against this hook shape in `w1-host-key-policy`):
the crate default ([`RejectAll`]) connects to nothing; case-1 pins the crate default ([`RejectAll`]) connects to nothing; case-1 pins
hard-decline on mismatch; case-2 assembly policies decide. The hard-decline on mismatch; case-2 assembly policies decide. The
crate is policy-blind — no accept-all built-in. crate is policy-blind, with no accept-all built-in.
- **The client Handler skeleton at fail-closed defaults** — the - **The client Handler skeleton at fail-closed defaults**: the
russh `client::Handler` impl the dial constructs: server-initiated russh `client::Handler` impl the dial constructs: server-initiated
channel opens (`server_channel_open_forwarded_tcpip` / channel opens (`server_channel_open_forwarded_tcpip` /
`_forwarded_streamlocal` / `_unknown` / x11 / agent) **declined `_forwarded_streamlocal` / `_unknown` / x11 / agent) **declined
cleanly** — never russh's accept-and-drop default (ADR-003 §4, the cleanly**, never russh's accept-and-drop default (ADR-003 §4, the
client-side symmetry rule; the acceptor registry that serves them client-side symmetry rule; the acceptor registry that serves them
is `w4-reverse-listen`'s surface, this task pins the default). is `w4-reverse-listen`'s surface, this task pins the default).
- **The authenticated connection handle** — the type everything else - **The authenticated connection handle**, the type everything else
rides: the russh `client::Handle` wrapped with the crate's surface rides: the russh `client::Handle` wrapped with the crate's surface
(publickey-only authentication from the key source — the family (publickey-only authentication from the key source, the family
contract; no password/keyboard-interactive paths), the policy and contract; no password/keyboard-interactive paths), the policy and
limits carried, the lifecycle primitives' raw material (full limits carried, the lifecycle primitives' raw material (full
cancellation/clean-close shape is `w4-lifecycle`'s). cancellation/clean-close shape is `w4-lifecycle`'s).
- **Test vehicle** — the client direction dials *vanilla* servers, so - **Test vehicle**, because the client direction dials *vanilla*
servers, so
the loopback harness (alkssh server) is not the vehicle: land an the loopback harness (alkssh server) is not the vehicle: land an
in-process russh **server double** (a fake `sshd` — hand-rolled in-process russh **server double** (a fake `sshd`: a hand-rolled
`russh::server` handler in `tests/common`) as the contract-test `russh::server` handler in `tests/common`) as the contract-test
vehicle, decide + record its placement and shape. Contract rows: vehicle, decide + record its placement and shape. Contract rows:
dial success (authenticated), each key-source failure posture dial success (authenticated), each key-source failure posture
@@ -62,7 +65,7 @@ connection handle this task lands.
wiring observable, server-initiated open declined cleanly. wiring observable, server-initiated open declined cleanly.
- **The smoke posture** decided + recorded (an alkssh-client ↔ - **The smoke posture** decided + recorded (an alkssh-client ↔
alkssh-server in-process loopback is the cheap real-stack smoke; alkssh-server in-process loopback is the cheap real-stack smoke;
a real-`sshd` dial is wave-5 e2e territory — never silently a real-`sshd` dial is wave-5 e2e territory, never silently
dropped). dropped).
## Acceptance Criteria ## Acceptance Criteria
@@ -92,10 +95,11 @@ connection handle this task lands.
surface), docs/architecture/decisions/003-feature-graph.md (§4 the surface), docs/architecture/decisions/003-feature-graph.md (§4 the
client-side fail-closed acceptor default this skeleton pins), client-side fail-closed acceptor default this skeleton pins),
docs/architecture/decisions/005-key-sources.md (decrypt-at-dial) docs/architecture/decisions/005-key-sources.md (decrypt-at-dial)
- docs/research/russh.md §Client surface (`client::Handler` inventory - docs/research/russh.md §Client surface (`client::Handler` inventory,
— `check_server_key` defaults to refuse; the where `check_server_key` defaults to refuse; the
`server_channel_open_*` hook set; `client::Config` knobs), `server_channel_open_*` hook set; `client::Config` knobs),
docs/research/reverse-runner.md (lessons 3/5 — keepalives docs/research/reverse-runner.md (lessons 3/5, with the
keepalives
load-bearing, the ephemeral-fleet policy posture) load-bearing, the ephemeral-fleet policy posture)
- docs/research/poc-1-auth-bridge.md §6/§7 (the client-side plumbing - docs/research/poc-1-auth-bridge.md §6/§7 (the client-side plumbing
facts), docs/research/poc-2-vault-keys.md (the dial lifecycle) facts), docs/research/poc-2-vault-keys.md (the dial lifecycle)
@@ -106,19 +110,19 @@ connection handle this task lands.
### What landed (2026-10-29) ### What landed (2026-10-29)
- `src/client.rs`: the dial surface — `dial(DialConfig)` over - `src/client.rs`: the dial surface, `dial(DialConfig)` over
[`DialConfig`], the four-step sequence (key load → bounded russh [`DialConfig`], the four-step sequence (key load → bounded russh
`Config` → policy at `check_server_key` → single publickey `Config` → policy at `check_server_key` → single publickey
auth), [`SshConnection`] (the connection handle: `limits()` auth), [`SshConnection`] (the connection handle: `limits()`
/ `address()` / `username()` / `is_closed()` / `close()` — the / `address()` / `username()` / `is_closed()` / `close()`, the
OQ-12 raw material; the full lifecycle surface is `w4-lifecycle`'s), OQ-12 raw material; the full lifecycle surface is `w4-lifecycle`'s),
the address parse (`host:port` + the bracket form; the address parse (`host:port` + the bracket form;
`DialError::Address` on refusal — dial-time, the family's `DialError::Address` on refusal (dial-time, matching the family's
no-construction-validation shape), and the doc-hidden no-construction-validation shape), and the doc-hidden
`russh_client_config` mapping (the bounded-config observable). `russh_client_config` mapping (the bounded-config observable).
- `src/client_handler.rs`: the client Handler skeleton (the - `src/client_handler.rs`: the client Handler skeleton (the
mirror of `handler.rs`'s server-direction handler; a separate mirror of `handler.rs`'s server-direction handler; a separate
module because `w4-op-rows` / `w4-reverse-listen` grow it) — module because `w4-op-rows` / `w4-reverse-listen` grow it), with
`check_server_key` wired to the injected policy `check_server_key` wired to the injected policy
(certificates fingerprint their inner key; the verdict passes (certificates fingerprint their inner key; the verdict passes
through), every `server_channel_open_*` hook overridden through), every `server_channel_open_*` hook overridden
@@ -126,16 +130,16 @@ connection handle this task lands.
`ChannelOpenFailure::AdministrativelyProhibited` reject (never `ChannelOpenFailure::AdministrativelyProhibited` reject (never
russh's accept-and-drop default), `should_accept_unknown_server_channel` russh's accept-and-drop default), `should_accept_unknown_server_channel`
the explicit `false`, decline events on the crate vocabulary. the explicit `false`, decline events on the crate vocabulary.
- `src/error.rs`: [`DialError`] — the dial direction's closed - `src/error.rs`: [`DialError`], the dial direction's closed
taxonomy (`KeySource` per-variant preserved / `Address` / taxonomy (`KeySource` per-variant preserved / `Address` /
`HostKeyRejected` / `AuthRejected` / `Wire`), with a manual `HostKeyRejected` / `AuthRejected` / `Wire`), with a manual
`From<russh::Error>` that upgrades russh's `UnknownKey` — the only `From<russh::Error>` that upgrades russh's `UnknownKey` (the only
producer of which is the `check_server_key` decline path — into producer of which is the `check_server_key` decline path) into
`HostKeyRejected`; hence the `Wire` variant carries no `#[from]` `HostKeyRejected`; hence the `Wire` variant carries no `#[from]`
(one conversion, one place). (one conversion, one place).
- `src/config.rs`: the **DialConfig amendment** (below) + doc - `src/config.rs`: the **DialConfig amendment** (below) + doc
updates; `src/observe.rs`: the client-side decline reasons updates; `src/observe.rs`: the client-side decline reasons
(`no-acceptor` — the skeleton's decline-all default; the registry (`no-acceptor`, the skeleton's decline-all default; the registry
is `w4-reverse-listen`'s) recorded in the vocabulary; `src/lib.rs`: is `w4-reverse-listen`'s) recorded in the vocabulary; `src/lib.rs`:
the re-exports (`dial` / `SshConnection` / `DialError` public; the the re-exports (`dial` / `SshConnection` / `DialError` public; the
handler + config mapping `#[doc(hidden)]` test surface, the handler + config mapping `#[doc(hidden)]` test surface, the
@@ -145,33 +149,33 @@ connection handle this task lands.
### Decided + recorded ### Decided + recorded
1. **The DialConfig amendment (`username`) — the task's sanctioned 1. **The DialConfig amendment (`username`), the task's sanctioned
course-correction, the only one.** The wave-1 shape carried course-correction, the only one.** The wave-1 shape carried
everything `client.md` §The dial surface names, but the everything `client.md` §The dial surface names, but the
USERAUTH_REQUEST structurally requires a user string USERAUTH_REQUEST structurally requires a user string
(`authenticate_publickey(user, key)`) and the remote end evaluates (`authenticate_publickey(user, key)`) and the remote end evaluates
it — no dial can exist without one. Decided with the it, and no dial can exist without one. Decided with the
`w3-seam-config` pattern: `username: String` is a required `w3-seam-config` pattern: `username: String` is a required
`DialConfig::new` parameter (no crate default — a default account `DialConfig::new` parameter (no crate default, since a default account
would be deployment policy smuggled into the crate; the empty would be deployment policy smuggled into the crate; the empty
string is accepted as data and is the remote end's to refuse). It string is accepted as data and is the remote end's to refuse). It
is *not identity* — the identity model stays fingerprint-only is *not identity*, as the identity model stays fingerprint-only
(ADR-004 decision 5, mirrored client-side), it is never logged (ADR-004 decision 5, mirrored client-side), it is never logged
(the event vocabulary carries no username), and it rides (the event vocabulary carries no username), and it rides
[`SshConnection`] as carried dial data. Wave-1 records amended [`SshConnection`] as carried dial data. Wave-1 records amended
(the config module + field docs). (the config module + field docs).
2. **The server double's placement/shape** 2. **The server double's placement/shape**
(`tests/common/sshd_double.rs`): a plain russh server (no alkssh (`tests/common/sshd_double.rs`): a plain russh server (no alkssh
in its serving path — vanilla-parity) on a **real loopback TCP in its serving path, hence vanilla-parity) on a **real loopback TCP
listener** (`127.0.0.1:0` — a dial target is an address the surface 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 resolves and connects to; the duplex-pair trick is the
server-direction harness's, and a dial has no `&mut Handle` slot server-direction harness's, and a dial has no `&mut Handle` slot
to attach one to). One dial per instance (doubles are cheap); the to attach one to). One dial per instance (doubles are cheap); the
handler resolves fingerprints against an injected accept set (the handler resolves fingerprints against an injected accept set (the
vanilla-`authorized_keys` stand-in) with every other auth hook an vanilla-`authorized_keys` stand-in) with every other auth hook an
explicit reject; the `auth_succeeded` hook hands the row the explicit reject; the `auth_succeeded` hook hands the row the
per-connection `russh::server::Handle` through a one-shot carrier — per-connection `russh::server::Handle` through a one-shot carrier,
the initiator for the server-initiated-open decline rows (the which is the initiator for the server-initiated-open decline rows (the
double calls `channel_open_*` at the client and observes each as double calls `channel_open_*` at the client and observes each as
`Err(ChannelOpenFailure(AdministrativelyProhibited))`); a `Err(ChannelOpenFailure(AdministrativelyProhibited))`); a
per-session outcome channel is the clean-close rows' observable; per-session outcome channel is the clean-close rows' observable;
@@ -180,14 +184,14 @@ connection handle this task lands.
3. **The smoke posture**: an alkssh-client ↔ alkssh-server 3. **The smoke posture**: an alkssh-client ↔ alkssh-server
in-process loopback over a real TCP socket, landed as the row in-process loopback over a real TCP socket, landed as the row
`smoke_alkssh_client_dials_the_alkssh_server_direction` `smoke_alkssh_client_dials_the_alkssh_server_direction`
(`tests/client_dial.rs` — both russh stacks, the full auth bridge (`tests/client_dial.rs`, with both russh stacks, the full auth bridge
server-side, the full custody + policy chain client-side). A server-side, the full custody + policy chain client-side). A
real-`sshd` dial is wave-5 e2e territory (`docs/plans/ real-`sshd` dial is wave-5 e2e territory (`docs/plans/
implementation.md` §Wave 5); nothing is silently dropped. implementation.md` §Wave 5); nothing is silently dropped.
4. **The decline-class mapping**: russh surfaces a policy decline as 4. **The decline-class mapping**: russh surfaces a policy decline as
its `UnknownKey` wire error; since the client's `check_server_key` its `UnknownKey` wire error; since the client's `check_server_key`
hook is the only producer of that class, the manual `From` makes hook is the only producer of that class, the manual `From` makes
it the semantic [`DialError::HostKeyRejected`] — every other russh it the semantic [`DialError::HostKeyRejected`], while every other russh
error stays [`DialError::Wire`]. error stays [`DialError::Wire`].
5. **The skeleton's unknown-type gate** (`should_accept_unknown_ 5. **The skeleton's unknown-type gate** (`should_accept_unknown_
server_channel` → explicit `false`) is code-pinned, not server_channel` → explicit `false`) is code-pinned, not
@@ -199,25 +203,25 @@ connection handle this task lands.
wire-row-pinned; the `_unknown` open hook is a defensive reject wire-row-pinned; the `_unknown` open hook is a defensive reject
behind the gate. behind the gate.
6. **Auth posture**: exactly one `authenticate_publickey` attempt 6. **Auth posture**: exactly one `authenticate_publickey` attempt
(ed25519-only, ADR-002 decision 4 — `None` is the whole (ed25519-only, ADR-002 decision 4, `None` being the whole
hash-algorithm posture); a refusal closes the attempt cleanly hash-algorithm posture); a refusal closes the attempt cleanly
(`Disconnect::ByApplication`) and surfaces (`Disconnect::ByApplication`) and surfaces
[`DialError::AuthRejected`]. russh's other auth methods [`DialError::AuthRejected`]. russh's other auth methods
(password / kbi / cert / gss / signer-injection — the signer seam (password / kbi / cert / gss / signer-injection, with the signer
is dismissed by ADR-005 decision 8) are never reached from seam dismissed by ADR-005 decision 8) are never reached from
[`SshConnection`]. [`SshConnection`].
7. **Bounded posture**: the knob mapping keeps the crate's documented 7. **Bounded posture**: the knob mapping keeps the crate's documented
postures (`keepalive` 15 s / 3, the wall clock always `Some`, postures (`keepalive` 15 s / 3, the wall clock always `Some`,
window/packet/buffer verbatim) and leaves window/packet/buffer verbatim) and leaves
russh's own default postures untouched (rekey `Limits`, russh's own default postures untouched (rekey `Limits`,
preferred algorithms, `client_id`, `nodelay` — no client-side preferred algorithms, `client_id`, `nodelay`. No client-side
auth-attempt knob exists at this pin and the single-attempt auth-attempt knob exists at this pin and the single-attempt
contract makes none needed). contract makes none needed).
### Verification ### Verification
`cargo test` (default) and `cargo test --all-features` (and `cargo test` (default) and `cargo test --all-features` (and
per-feature `--features {session,git,tunnel}`) all green — 14 test per-feature `--features {session,git,tunnel}`) all green, with 14 test
binaries each, the new `tests/client_dial.rs` carrying 10 rows; binaries each, the new `tests/client_dial.rs` carrying 10 rows;
`cargo clippy --all-features --all-targets` and `cargo clippy `cargo clippy --all-features --all-targets` and `cargo clippy
--all-targets` both `-D warnings` clean; `cargo fmt --check` clean; --all-targets` both `-D warnings` clean; `cargo fmt --check` clean;
@@ -229,11 +233,11 @@ The client direction's substrate landed: `dial(config) ->
Result<SshConnection, DialError>` runs the fail-closed four-step Result<SshConnection, DialError>` runs the fail-closed four-step
sequence (decrypt-at-dial key load with every no-dial posture sequence (decrypt-at-dial key load with every no-dial posture
distinct; the bounded russh client `Config` from `Limits`; the distinct; the bounded russh client `Config` from `Limits`; the
injected host-key policy at `check_server_key` — `RejectAll` injected host-key policy at `check_server_key`, where `RejectAll`
connects to nothing; a single publickey-only auth attempt) and connects to nothing; a single publickey-only auth attempt) and
returns the authenticated connection handle everything later in the returns the authenticated connection handle everything later in the
wave rides. One sanctioned `DialConfig` amendment: the required wave rides. One sanctioned `DialConfig` amendment: the required
`username` field (the USERAUTH_REQUEST's user string — no crate `username` field (the USERAUTH_REQUEST's user string; no crate
default, never identity, never logged). The fail-closed client default, never identity, never logged). The fail-closed client
Handler skeleton declines every server-initiated open on the wire Handler skeleton declines every server-initiated open on the wire
(the fake-`sshd` double in `tests/common` pins six hook rows + (the fake-`sshd` double in `tests/common` pins six hook rows +
+34 -33
View File
@@ -1,6 +1,6 @@
--- ---
id: w4-git-exit-close id: w4-git-exit-close
name: Wave 4 — git row hardening (status-before-close by construction — wave-3 gate booked item 1) name: "Wave 4: git row hardening (status-before-close by construction, wave-3 gate booked item 1)"
status: completed status: completed
depends_on: [w3-git-mapping] depends_on: [w3-git-mapping]
scope: narrow scope: narrow
@@ -15,7 +15,7 @@ Close the wave-3 gate's only structural booked finding
(`review-wave-3` §Booked findings item 1): the git row's (`review-wave-3` §Booked findings item 1): the git row's
status-before-close is honored **by scheduling, not by construction**. status-before-close is honored **by scheduling, not by construction**.
Today `serve_duplex_resolved` splits the channel stream internally and Today `serve_duplex_resolved` splits the channel stream internally and
its session halves drop when the serve returns — russh's its session halves drop when the serve returns, so russh's
`ChannelCloseOnDrop` rx-half emits a server→client `CHANNEL_CLOSE` `ChannelCloseOnDrop` rx-half emits a server→client `CHANNEL_CLOSE`
*inside* the seam call, before `serve_git_row`'s explicit *inside* the seam call, before `serve_git_row`'s explicit
`exit_status_request`. The ordering currently holds (the task's `exit_status_request`. The ordering currently holds (the task's
@@ -23,26 +23,27 @@ continuation queues the report in the same poll, ahead of the drop's
send) and every wire row passes, but a full `Sender<Msg>` buffer at send) and every wire row passes, but a full `Sender<Msg>` buffer at
the serve tail could order the implicit close first and silently drop the serve tail could order the implicit close first and silently drop
the report (russh no-ops `exit_status_request` for an already-closed the report (russh no-ops `exit_status_request` for an already-closed
channel) — a silent exit-report loss on a published row. channel), a silent exit-report loss on a published row.
The fix wants a seam/shape change — **decide + record which**: The fix wants a seam/shape change (decide and record which):
- split the channel halves in `serve_git_row` and pass them into the - split the channel halves in `serve_git_row` and pass them into the
seam (the report then rides a half the seam never closes), or seam (the report then rides a half the seam never closes), or
- queue the exit report while the stream is alive (the report's send - queue the exit report while the stream is alive (the report's send
ordered before any close-contracting drop by construction), or ordered before any close-contracting drop by construction), or
- another shape that makes the ordering structural rather than - another shape that makes the ordering structural rather than
scheduler-luck — the gate's reasoning and the two named options are scheduler-luck; the gate's reasoning and the two named options are
the starting points, not the bounds. the starting points, not the bounds.
Constraints: every existing git wire row stays green (no behavior Constraints: every existing git wire row stays green (no behavior
change on the happy path — this is ordering hardening, not semantics change on the happy path, since this is ordering hardening, not
change); the spawn-and-return constraint (ADR-006 decision 4) holds — semantics
the reporting still rides the spawned task; the tty rows are NOT in change); the spawn-and-return constraint (ADR-006 decision 4) holds,
because the reporting still rides the spawned task; the tty rows are NOT in
this class (their report precedes any close-contracting drop in one this class (their report precedes any close-contracting drop in one
task — the gate verified; don't touch them). New wire row(s) pinning task, which the gate verified; don't touch them). New wire row(s) pinning
the construction: a saturated `Sender<Msg>` buffer at the serve tail the construction: a saturated `Sender<Msg>` buffer at the serve tail
(the failure scenario made real) still delivers the exit report — (the failure scenario made real) still delivers the exit report,
the by-construction property proven on the wire, not by reasoning. the by-construction property proven on the wire, not by reasoning.
## Acceptance Criteria ## Acceptance Criteria
@@ -61,10 +62,10 @@ the by-construction property proven on the wire, not by reasoning.
- tasks/implementation/review-wave-3.md §Booked findings item 1 (the - tasks/implementation/review-wave-3.md §Booked findings item 1 (the
full finding + the two named fix shapes) full finding + the two named fix shapes)
- docs/architecture/mapping.md (the git exec row — exit-status both - docs/architecture/mapping.md (the git exec row, exit-status both
directions), docs/architecture/decisions/006-mapping-bridge-and-dispatch.md directions), docs/architecture/decisions/006-mapping-bridge-and-dispatch.md
(decision 4 — spawn-and-return) (decision 4, spawn-and-return)
- src/git_map.rs, src/serve.rs (`report_exit_status_then_close` — the - src/git_map.rs, src/serve.rs (`report_exit_status_then_close`, the
wave-2 reporting mechanics this hardening composes with), alkgit wave-2 reporting mechanics this hardening composes with), alkgit
0.1.0 `serve_duplex_resolved` (the seam whose half-ownership is the 0.1.0 `serve_duplex_resolved` (the seam whose half-ownership is the
question) question)
@@ -73,8 +74,8 @@ the by-construction property proven on the wire, not by reasoning.
### The shape decided (2026-10-29) ### The shape decided (2026-10-29)
Went with the first named option — **split the channel halves in Went with the first named option, **splitting the channel halves in
`serve_git_row` and pass them into the seam** — refined by one russh `serve_git_row` and passing them into the seam**, refined by one russh
0.65 reality: `russh::channels::io::{ChannelRx, ChannelTx}` are 0.65 reality: `russh::channels::io::{ChannelRx, ChannelTx}` are
*private* at this pin, so the split-halves duplex is rebuilt from the *private* at this pin, so the split-halves duplex is rebuilt from the
public API in `src/git_map.rs`: public API in `src/git_map.rs`:
@@ -83,14 +84,14 @@ public API in `src/git_map.rs`:
[`ChannelHalveReader`]: the owned `ChannelReadHalf` [`ChannelHalveReader`]: the owned `ChannelReadHalf`
(`channel.split()`), a `poll_read` state machine whose observation (`channel.split()`), a `poll_read` state machine whose observation
semantics repin russh's private `ChannelRx` *without* the semantics repin russh's private `ChannelRx` *without* the
`ChannelCloseOnDrop` wrapper — `Data` fills (partial reads `ChannelCloseOnDrop` wrapper, so `Data` fills (partial reads
buffered), `ExtendedData`/other non-data messages swallowed and buffered), `ExtendedData`/other non-data messages swallowed and
re-read (the `_`-arm parity), `Eof` ends the stream, the peer's re-read (the `_`-arm parity), `Eof` ends the stream, the peer's
`Close` leaves the reader to the channel's sender-death EOF `Close` leaves the reader to the channel's sender-death EOF
(`wait()` → `None`). The `wait()` access is *future-owned*: each (`wait()` → `None`). The `wait()` access is *future-owned*: each
read round-trips the half through a boxed future that parks it read round-trips the half through a boxed future that parks it
inside `ChannelReadHalf::wait` and hands it back with the observed inside `ChannelReadHalf::wait` and hands it back with the observed
message — fully owned (`'static`-usable, the seam's `T` bound) message, fully owned (`'static`-usable, the seam's `T` bound)
across every park/buffer/drop state, no extra task, and **no across every park/buffer/drop state, no extra task, and **no
`Drop` path ever messages the wire**. `Drop` path ever messages the wire**.
- **The writer** is the write half's `ChannelTx` via - **The writer** is the write half's `ChannelTx` via
@@ -101,20 +102,20 @@ public API in `src/git_map.rs`:
the seam's `'static` bound satisfiable without parking the half. the seam's `'static` bound satisfiable without parking the half.
`poll_shutdown` still sends EOF (exactly `into_stream`'s). `poll_shutdown` still sends EOF (exactly `into_stream`'s).
The result: **nothing the seam drops ever messages the wire** — the The result: **nothing the seam drops ever messages the wire**, with the
report's `exit_status_request` send is the sole close-contracting report's `exit_status_request` send is the sole close-contracting
sender, and its queue position ahead of the explicit close holds by sender, and its queue position ahead of the explicit close holds by
construction (sequential awaited sends on the one `Handle`), construction (sequential awaited sends on the one `Handle`),
saturated buffer or not. The hazard's premise (russh's saturated buffer or not. The hazard's premise (russh's
`ChannelCloseOnDrop` `Drop` spawning a `CHANNEL_CLOSE` send that can `ChannelCloseOnDrop` `Drop` spawning a `CHANNEL_CLOSE` send that can
win a queue position on the bounded `Sender<Msg>` — win a queue position on the bounded `Sender<Msg>`
`event_buffer_size`, default 10 — before the report's) is *removed*, (`event_buffer_size`, default 10) before the report's, is *removed*,
not raced. not raced.
### The wire row (the by-construction property pinned) ### The wire row (the by-construction property pinned)
`tests/git_mapping.rs::backlogged_seam_tail_still_reports_exit_status_before_close` `tests/git_mapping.rs::backlogged_seam_tail_still_reports_exit_status_before_close`,
— the finding's failure scenario made real on the wire: the serve's the finding's failure scenario made real on the wire: the serve's
answer volume (300 one-pkt-line ref answers) exceeds the entire answer volume (300 one-pkt-line ref answers) exceeds the entire
outbound pipeline via the new client-direction data gate outbound pipeline via the new client-direction data gate
(`tests/common/harness.rs::DataGate` + the harness handler's `data` (`tests/common/harness.rs::DataGate` + the harness handler's `data`
@@ -125,18 +126,18 @@ serve's remaining answer writes pend (the serve tail blocked). The
gate's release then drives the serve tail through the just-unwinding gate's release then drives the serve tail through the just-unwinding
contention, and the row pins: the whole backlogged answer drains contention, and the row pins: the whole backlogged answer drains
**through the same queue the report rides**, then exit-status(0), then **through the same queue the report rides**, then exit-status(0), then
close — order asserted, exit code asserted. close; the order is asserted, the exit code asserted.
**The row falsifies the old shape.** With a temporary one-line revert **The row falsifies the old shape.** With a temporary one-line revert
of `serve_git_row` to `channel.into_stream()` the row FAILED of `serve_git_row` to `channel.into_stream()` the row FAILED
**15/15** runs — the implicit close won the frozen-drain queue race **15/15** runs, as the implicit close won the frozen-drain queue race
("expected exit-status(0), got Some(Close)"), the silent report loss ("expected exit-status(0), got Some(Close)"), the silent report loss
the gate booked. With the split-halves shape: **10/10 green**. So the the gate booked. With the split-halves shape: **10/10 green**. So the
row is a two-sided pin: deterministic red under the hazard, green by row is a two-sided pin: deterministic red under the hazard, green by
construction without it. construction without it.
Test-vehicle additions (test surface, freely versioned): the doubles' Test-vehicle additions (test surface, freely versioned): the doubles'
per-repo serve gate (`tests/common/git_doubles.rs`: `install_gate` — per-repo serve gate (`tests/common/git_doubles.rs`: `install_gate`, a
race-safe double-check `MemGate`, gating `GitRefs::list_refs`, so the race-safe double-check `MemGate`, gating `GitRefs::list_refs`, so the
row schedules the serve's stages against prepared wire state) and the row schedules the serve's stages against prepared wire state) and the
harness's `DataGate` + `drive_with_data_gate` (the `drive*` bodies harness's `DataGate` + `drive_with_data_gate` (the `drive*` bodies
@@ -145,15 +146,15 @@ signatures unchanged).
### Constraints check ### Constraints check
- **Every existing git wire row green** — the full git suite - **Every existing git wire row green**, with the full git suite
(`tests/git_mapping.rs`, 12 rows) and every other loopback suite: (`tests/git_mapping.rs`, 12 rows) and every other loopback suite:
no behavior change on the happy path (the wire order the rows pin — no behavior change on the happy path (the wire order the rows pin,
data, exit-status, close — is identical under both shapes when the i.e. data, exit-status, close, is identical under both shapes when
race doesn't fire). the race doesn't fire).
- **Spawn-and-return intact** (ADR-006 decision 4): the reporting - **Spawn-and-return intact** (ADR-006 decision 4): the reporting
still rides the spawned serving task through the cloned still rides the spawned serving task through the cloned
`Session::handle()`; `serve.rs`'s mechanics untouched. `Session::handle()`; `serve.rs`'s mechanics untouched.
- **The tty rows untouched** (not in this class — the gate verified). - **The tty rows untouched** (not in this class; the gate verified that).
- **No API/limits change**: `event_buffer_size` stays russh's default - **No API/limits change**: `event_buffer_size` stays russh's default
posture (the adapter docs' recorded default posture preserved); the posture (the adapter docs' recorded default posture preserved); the
row pins the default's value with a note that a russh default row pins the default's value with a note that a russh default
@@ -163,7 +164,7 @@ signatures unchanged).
**Completed.** The git row's status-before-close now holds **by **Completed.** The git row's status-before-close now holds **by
construction**: `serve_git_row` no longer hands russh's construction**: `serve_git_row` no longer hands russh's
close-on-drop `ChannelStream` (`into_stream()`) to the seam — the close-on-drop `ChannelStream` (`into_stream()`) to the seam; the
seam rides a split-halves duplex (`git_channel_duplex` → seam rides a split-halves duplex (`git_channel_duplex` →
`ChannelHalveReader` over the owned read half + the write half's `ChannelHalveReader` over the owned read half + the write half's
`ChannelTx` via the capture-erased trait object) where **no drop `ChannelTx` via the capture-erased trait object) where **no drop
@@ -173,8 +174,8 @@ unconditional (russh 0.65's private `ChannelRx`/`ChannelTx` noted;
the semantics repinned from the public API). New wire row pins the the semantics repinned from the public API). New wire row pins the
by-construction property on a genuinely saturated tail (volume flood by-construction property on a genuinely saturated tail (volume flood
→ frozen client loop → duplex → bounded message queue; gate release → frozen client loop → duplex → bounded message queue; gate release
→ the tail through the unwinding contention) — and falsifies the old → the tail through the unwinding contention), and it also falsifies
shape deterministically (15/15 red under a temporary the old shape deterministically (15/15 red under a temporary
`into_stream()` revert, 10/10 green with the fix). Doubles gained a `into_stream()` revert, 10/10 green with the fix). Doubles gained a
race-safe per-repo serve gate; the harness gained the client-side race-safe per-repo serve gate; the harness gained the client-side
data gate + `drive_with_data_gate` (the `drive*` cores factored, data gate + `drive_with_data_gate` (the `drive*` cores factored,
+37 -37
View File
@@ -1,6 +1,6 @@
--- ---
id: w4-lifecycle id: w4-lifecycle
name: Wave 4 — lifecycle primitives (cancellable handles + clean disconnect) name: "Wave 4: lifecycle primitives (cancellable handles + clean disconnect)"
status: completed status: completed
depends_on: [w4-op-rows, w4-reverse-listen] depends_on: [w4-op-rows, w4-reverse-listen]
scope: narrow scope: narrow
@@ -13,24 +13,24 @@ level: implementation
Land the OQ-12 resolution in code (`client.md` §Lifecycle Land the OQ-12 resolution in code (`client.md` §Lifecycle
primitives): the primitives that make assembly-side reconnect/restore primitives): the primitives that make assembly-side reconnect/restore
policy *possible* — and nothing more: policy *possible*, and nothing more:
- **Explicitly cancellable handles** — every listener/handle the - **Explicitly cancellable handles**, so every listener/handle the
client direction returns (the `-R` listener handles from client direction returns (the `-R` listener handles from
`w4-reverse-listen`, the connection handle from `w4-dial`) carries `w4-reverse-listen`, the connection handle from `w4-dial`) carries
an explicit cancellation/clean-close path; no handle relies on drop an explicit cancellation/clean-close path; no handle relies on drop
timing alone for wire-visible state. timing alone for wire-visible state.
- **Clean disconnect** — `Disconnect::ByApplication` (russh's client - **Clean disconnect**, namely `Disconnect::ByApplication` (russh's client
`Handle::disconnect`): the explicit-close path leaves no half-open `Handle::disconnect`): the explicit-close path leaves no half-open
channels, observed clean by the peer (row-pinned through the channels, observed clean by the peer (row-pinned through the
server double). server double).
- **No reconnect or restore policy owned by the crate** — the - **No reconnect or restore policy owned by the crate**, hence the
module-doc posture: reconnect-and-restore (rebinding persisted module-doc posture: reconnect-and-restore (rebinding persisted
tunnel ports — the alkminer flow, reverse-runner fact 4) is tunnel ports, the alkminer flow, per reverse-runner fact 4) is
assembly policy; the crate provides bound ports, handles, assembly policy; the crate provides bound ports, handles,
cancellation, clean close. The docs state the boundary explicitly cancellation, clean close. The docs state the boundary explicitly
so no later task drifts into baking policy in. so no later task drifts into baking policy in.
- **Contract rows through the server double** — cancel a listener - **Contract rows through the server double**: cancel a listener
mid-connection (the server's forward is torn down, the bound port mid-connection (the server's forward is torn down, the bound port
released); clean disconnect observed by the peer; the released); clean disconnect observed by the peer; the
connection-handle cancellation path; the no-policy posture is a connection-handle cancellation path; the no-policy posture is a
@@ -51,27 +51,27 @@ policy *possible* — and nothing more:
## References ## References
- docs/architecture/client.md §Lifecycle primitives (the OQ-12 - docs/architecture/client.md §Lifecycle primitives (the OQ-12
resolution), docs/architecture/open-questions.md (OQ-12 — resolved: resolution), docs/architecture/open-questions.md (OQ-12, which
primitives only) resolved as primitives only)
- docs/research/reverse-runner.md (fact 4 — reconnect-and-restore is - docs/research/reverse-runner.md (fact 4, where reconnect-and-restore is
assembly policy, kept in view so the crate never bakes it in) assembly policy, kept in view so the crate never bakes it in)
- docs/research/russh.md §Client surface (`Handle::disconnect`, the - docs/research/russh.md §Client surface (`Handle::disconnect`, the
forward cancel forms) forward cancel forms)
- src/tunnel_map.rs (the server-side teardown posture — the cancel - src/tunnel_map.rs (the server-side teardown posture, the cancel
semantics mirror) semantics mirror)
## Notes ## Notes
### What landed (2026-10-29) ### What landed (2026-10-29)
- `src/client_accept.rs`: the **listener handles** — - `src/client_accept.rs`: the **listener handles**, namely the
[`ReverseListener`] + [`ReverseEndpoint`] (the handles' surface the [`ReverseListener`] + [`ReverseEndpoint`] (the handles' surface the
`w4-reverse-listen` block deferred to this task): `reverse_listen` / `w4-reverse-listen` block deferred to this task): `reverse_listen` /
`reverse_listen_unix` return a handle carrying the *actually-bound* `reverse_listen_unix` return a handle carrying the *actually-bound*
endpoint (the `bound_port()` accessor, `None` for the unix shape) endpoint (the `bound_port()` accessor, `None` for the unix shape)
with the explicit `cancel()`; the shared cancel helpers with the explicit `cancel()`; the shared cancel helpers
(`cancel_reverse_tcp` / `cancel_reverse_unix`) — both the handles' (`cancel_reverse_tcp` / `cancel_reverse_unix`), and both the
`cancel()` and the stateless wire forms compose into the same handles' `cancel()` and the stateless wire forms compose into the same
paths; the confirmed cancel releases the connection's listener paths; the confirmed cancel releases the connection's listener
count exactly once (the repeat is the wire's unknown-listener count exactly once (the repeat is the wire's unknown-listener
refusal, no release). The recorded decisions (module docs): no refusal, no release). The recorded decisions (module docs): no
@@ -80,12 +80,12 @@ policy *possible* — and nothing more:
its `cancel()` works against a live stack regardless of the its `cancel()` works against a live stack regardless of the
connection handle's own holders; the stateless forms ride beside connection handle's own holders; the stateless forms ride beside
1:1. 1:1.
- `src/client.rs`: the **`SshConnection` restructure** — the russh - `src/client.rs`: the **`SshConnection` restructure**, where the russh
handle, limits/labels, acceptor registry and listener budget ride handle, limits/labels, acceptor registry and listener budget ride
one `ConnectionShared` behind the `Arc` (the concurrency-safe one `ConnectionShared` behind the `Arc` (the concurrency-safe
sharing the handles' independent `cancel()`s need; the russh client sharing the handles' independent `cancel()`s need; the russh client
request methods all take `&self` at this pin, so the arc is the request methods all take `&self` at this pin, so the arc is the
sharing shape — no mutex), `SshConnection`'s public surface sharing shape, with no mutex), `SshConnection`'s public surface
unchanged (`close()` consumes the handle and sends unchanged (`close()` consumes the handle and sends
`Disconnect::ByApplication`), and the module docs' new **§Lifecycle `Disconnect::ByApplication`), and the module docs' new **§Lifecycle
posture** (the primitives-not-policy boundary, reverse-runner fact posture** (the primitives-not-policy boundary, reverse-runner fact
@@ -98,16 +98,16 @@ policy *possible* — and nothing more:
- `tests/client_dial.rs`: the every-build row - `tests/client_dial.rs`: the every-build row
`dropping_the_connection_is_an_abrupt_teardown_close_is_the_only_clean_path` `dropping_the_connection_is_an_abrupt_teardown_close_is_the_only_clean_path`
(the `next_session_outcome` observable: drop → the peer's session (the `next_session_outcome` observable: drop → the peer's session
ends with an **error wire class, never the clean `Ok`** — the ends with an **error wire class, never the clean `Ok`**, the
boundary pinned both ways: the clean `Ok` is only `close()`'s). boundary pinned both ways: the clean `Ok` is only `close()`'s).
- `tests/client_reverse_listen.rs`: three new lifecycle rows — - `tests/client_reverse_listen.rs`: three new lifecycle rows:
`dropping_the_listener_handle_leaves_no_drop_timing_wire_state` `dropping_the_listener_handle_leaves_no_drop_timing_wire_state`
(drop → the peer's listen still serves a round-tripping data leg; (drop → the peer's listen still serves a round-tripping data leg;
only the explicit cancel tears down), only the explicit cancel tears down),
`the_listener_handle_cancel_tears_the_forward_down_and_releases_the_budget` `the_listener_handle_cancel_tears_the_forward_down_and_releases_the_budget`
(ceiling → refuse pre-wire; the handle's cancel confirms and (ceiling → refuse pre-wire; the handle's cancel confirms and
releases; the next listen admits; the repeat rides the wire and releases; the next listen admits; the repeat rides the wire and
refuses — no budget manufactured), refuses, with no budget manufactured),
`cancel_mid_data_leg_tears_the_forward_down_mid_connection` (the `cancel_mid_data_leg_tears_the_forward_down_mid_connection` (the
cancel lands while a data leg is mid-pump; the forward tears down, cancel lands while a data leg is mid-pump; the forward tears down,
the connection stays usable, ends clean); the existing rows the connection stays usable, ends clean); the existing rows
@@ -117,46 +117,46 @@ policy *possible* — and nothing more:
### Decided + recorded ### Decided + recorded
1. **The handles' shape — single `ReverseListener`, one 1. **The handles' shape, a single `ReverseListener`, one
`ReverseEndpoint` enum, not two handle types.** The endpoint enum `ReverseEndpoint` enum, not two handle types.** The endpoint enum
mirrors the wire's two bind shapes (an actually-bound mirrors the wire's two bind shapes (an actually-bound
address+port / a socket path); the tcp-flavored `bound_port()` address+port / a socket path); the tcp-flavored `bound_port()`
accessor returns `Option` (the unix shape carries no port — accessor returns `Option` (the unix shape carries no port,
honest, no panic path). A second handle type would have duplicated honestly with no panic path). A second handle type would have duplicated
the cancel plumbing for a shape the cancel forms already treat as the cancel plumbing for a shape the cancel forms already treat as
raw strings. raw strings.
2. **The abrupt-drop fact (row-pinned, corrected mid-task):** at this 2. **The abrupt-drop fact (row-pinned, corrected mid-task):** at this
russh pin dropping the *last* wire-stack handle closes the russh pin dropping the *last* wire-stack handle closes the
`Handle`'s sender; the client runner's `receiver.recv()` → `None` `Handle`'s sender; the client runner's `receiver.recv()` → `None`
sets `disconnected` and the transport tears down abruptly — the sets `disconnected` and the transport tears down abruptly; the
peer's session outcome is an error wire class (`Err(Disconnect)` peer's session outcome is an error wire class (`Err(Disconnect)`
observed through the double). So the honest boundary is not observed through the double). So the honest boundary is not
"drop does nothing" but **"drop is never the clean path"** — the "drop does nothing" but **"drop is never the clean path"**, since the
clean `Ok` session end is only `close()`'s clean `Ok` session end is only `close()`'s
(`Disconnect::ByApplication`), row-pinned in both directions (`Disconnect::ByApplication`), row-pinned in both directions
(abrupt-on-drop, clean-on-close) through the server double. No (abrupt-on-drop, clean-on-close) through the server double. No
handle relies on drop timing for *clean* wire state, and the handle relies on drop timing for *clean* wire state, and the
listener handles' drop is a pure local no-op (no `Drop` hook; a listener handles' drop is a pure local no-op (no `Drop` hook; a
cancellation is async and cannot hide in one — the listen lives at cancellation is async and cannot hide in one, and the listen lives at
the peer until an explicit cancel or the connection's end). the peer until an explicit cancel or the connection's end).
3. **The `SshConnection` restructure is a shape change, not a 3. **The `SshConnection` restructure is a shape change, not a
surface change** — the public surface (the wrapped methods, surface change**: the public surface (the wrapped methods,
`limits()`/`address()`/`username()`/`is_closed()`/`acceptors()`/ `limits()`/`address()`/`username()`/`is_closed()`/`acceptors()`/
`close()`) is unchanged; only the internals moved behind the arc `close()`) is unchanged; only the internals moved behind the arc
(the handles' independent cancel). No `Clone` was added — not in (the handles' independent cancel). No `Clone` was added, which is not in
the contract, and the arc means a cloned handle's `close()` the contract, and the arc means a cloned handle's `close()`
semantics would need their own contract; wave-5 review can add it semantics would need their own contract; wave-5 review can add it
if an assembly shape needs it. if an assembly shape needs it.
4. **A live `ReverseListener` keeps the wire stack alive after the 4. **A live `ReverseListener` keeps the wire stack alive after the
connection handle's drop** — the arc's ownership side effect, connection handle's drop**, an arc ownership side effect recorded
recorded (the alkminer flow's shape: the listener handle outlives (the alkminer flow's shape: the listener handle outlives
the connection slot; its `cancel()` composes on the still-live the connection slot; its `cancel()` composes on the still-live
stack). stack).
### Verification ### Verification
`cargo test` (default), `--features session`, `--features git`, `cargo test` (default), `--features session`, `--features git`,
`--features tunnel`, `--all-features` — all green (16 test binaries `--features tunnel`, `--all-features`: all green (16 test binaries
each); `cargo clippy --all-targets -- -D warnings` and each); `cargo clippy --all-targets -- -D warnings` and
`cargo clippy --all-features --all-targets -- -D warnings` clean; `cargo clippy --all-features --all-targets -- -D warnings` clean;
`cargo fmt --check` clean; `cargo doc --no-deps` warning-free in the `cargo fmt --check` clean; `cargo doc --no-deps` warning-free in the
@@ -179,20 +179,20 @@ Details in the `Landed` block below.
Landed (`feat(client)`: w4-lifecycle): Landed (`feat(client)`: w4-lifecycle):
- **`src/client_accept.rs`** — the listener handles: `ReverseListener` - **`src/client_accept.rs`**, the listener handles: `ReverseListener`
(+ `ReverseEndpoint`) — `reverse_listen` / `reverse_listen_unix` (+ `ReverseEndpoint`), which `reverse_listen` / `reverse_listen_unix`
now return them; the actually-bound endpoint carried, the explicit now return; the actually-bound endpoint carried, the explicit
`cancel()` the wire-honest teardown, the listener count released `cancel()` the wire-honest teardown, the listener count released
exactly once per confirmed cancel through the shared cancel paths exactly once per confirmed cancel through the shared cancel paths
the stateless wire forms compose into too; no `Drop` hides a wire the stateless wire forms compose into too; no `Drop` hides a wire
op (recorded, row-pinned no-drop-timing wire state). op (recorded, row-pinned no-drop-timing wire state).
- **`src/client.rs`** — the `SshConnection` internals behind the arc - **`src/client.rs`**, the `SshConnection` internals behind the arc
(`ConnectionShared`) + the §Lifecycle posture module docs (the (`ConnectionShared`) + the §Lifecycle posture module docs (the
primitives-not-policy boundary, the abrupt-drop/clean-close facts); primitives-not-policy boundary, the abrupt-drop/clean-close facts);
the public surface unchanged (`close()` is the clean path). the public surface unchanged (`close()` is the clean path).
- **`src/lib.rs`** — the handle exports; **`docs/architecture/ - **`src/lib.rs`** for the handle exports; **`docs/architecture/
client.md`** — the §Lifecycle primitives landing block. client.md`** for the §Lifecycle primitives landing block.
- **Rows** — 3 new tunnel-build rows through the server double - **Rows**, with 3 new tunnel-build rows through the server double
(`tests/client_reverse_listen.rs`: the drop-handle no-wire-state (`tests/client_reverse_listen.rs`: the drop-handle no-wire-state
row, the handle-cancel + budget-release row, the cancel-mid-data-leg row, the handle-cancel + budget-release row, the cancel-mid-data-leg
row) + 1 new every-build row (`tests/client_dial.rs`: the abrupt- row) + 1 new every-build row (`tests/client_dial.rs`: the abrupt-
+56 -55
View File
@@ -1,6 +1,6 @@
--- ---
id: w4-op-rows id: w4-op-rows
name: Wave 4 — the client op rows (exec/pty, direct-tcpip, git exec, sftp decline) name: "Wave 4: the client op rows (exec/pty, direct-tcpip, git exec, sftp decline)"
status: completed status: completed
depends_on: [w4-dial] depends_on: [w4-dial]
scope: moderate scope: moderate
@@ -11,54 +11,54 @@ level: implementation
## Description ## Description
Land the op rows — family op → vanilla translation per Land the op rows: family op → vanilla translation per
`client.md`'s table, pointed upstream at a vanilla server (the `client.md`'s table, pointed upstream at a vanilla server (the
reverse-runner shape: the hub drives every channel open). Type reverse-runner shape: the hub drives every channel open). Type
symmetry at the seam is the contract: family-protocol types in, symmetry at the seam is the contract: family-protocol types in,
vanilla wire ops out, no vanilla-concept leaks up. The POC-3 §D.3 vanilla wire ops out, no vanilla-concept leaks up. The POC-3 §D.3
mechanics are the body of knowledge this task lands in code: mechanics are the body of knowledge this task lands in code:
- **Session exec/pty rows** (under `session`) — `channel_open_session` - **Session exec/pty rows** (under `session`): `channel_open_session`
+ the exec/pty-req/shell-req translations onto the alktty session + the exec/pty-req/shell-req translations onto the alktty session
types (pipe vs PTY; exit semantics): `Channel::exec()` — **no types (pipe vs PTY; exit semantics): `Channel::exec()`, with **no
`request_exec` at this pin**; Success/Failure arrive on the channel `request_exec` at this pin**; Success/Failure arrive on the channel
queue via `wait()`-drain, **not** as `Err` from the request call queue via `wait()`-drain, **not** as `Err` from the request call
(drain until the expected message, `continue` past the rest); (drain until the expected message, `continue` past the rest);
`window_change`/`signal` are fire-and-forget and must be sent `window_change`/`signal` are fire-and-forget and must be sent
**before** `into_stream()` (the Channel is consumed by conversion); **before** `into_stream()` (the Channel is consumed by conversion);
**never consume the channel queue before `into_stream()`** (a **never consume the channel queue before `into_stream()`**, because a
`wait()` on a fresh channel breaks it — POC-1 §6, both sides). The `wait()` on a fresh channel breaks it (POC-1 §6, both sides). The
data pump + the exit consumption mirror the server direction's data pump + the exit consumption mirror the server direction's
derivation contract (the pipe-mode pid-only signal caveat rides the derivation contract (the pipe-mode pid-only signal caveat rides the
alktty exit-chunk semantics — `mapping.md`'s contract, consumed not alktty exit-chunk semantics, `mapping.md`'s contract, consumed not
re-derived). re-derived).
- **`direct-tcpip` typed dial** (under `tunnel`) — the row the `-L` - **`direct-tcpip` typed dial** (under `tunnel`), the row the `-L`
leg and the alksocks `DialFn` compose over: `channel_open_direct_tcpip` leg and the alksocks `DialFn` compose over: `channel_open_direct_tcpip`
resolves to `Err(ChannelOpenFailure)` on server reject (POC-1 §6) — resolves to `Err(ChannelOpenFailure)` on server reject (POC-1 §6),
the typed open, no `wait()` before `into_stream`; **the connection giving the typed open with no `wait()` before `into_stream`; **the connection
stays usable after a declined open** (pinned by row). The stays usable after a declined open** (pinned by row). The
`direct-streamlocal` twin with the runtime-Unix gate (OQ-11 `direct-streamlocal` twin with the runtime-Unix gate (OQ-11
symmetry: universal compile, clean off-Unix decline). symmetry: universal compile, clean off-Unix decline).
- **Git exec consumption** (under `git`) — the same exec grammar the - **Git exec consumption** (under `git`), the same exec grammar the
server side parses (`git-{upload,receive}-pack '/repo'`), server side parses (`git-{upload,receive}-pack '/repo'`),
**reused, not forked** — pointed upstream at a vanilla `sshd`: **reused, not forked**, pointed upstream at a vanilla `sshd`:
parse → exec → the pack duplex pumped → exit-status consumed. The parse → exec → the pack duplex pumped → exit-status consumed. The
per-length limits and service-only decline classes ride the shared per-length limits and service-only decline classes ride the shared
grammar's verdicts. grammar's verdicts.
- **The sftp subsystem placeholder decline** (every build, OQ-13) — - **The sftp subsystem placeholder decline** (every build, OQ-13),
the client-side half of the tested decline placeholder: a the client-side half of the tested decline placeholder: a
`subsystem "sftp"` open declines cleanly in every build (the `subsystem "sftp"` open declines cleanly in every build (the
feature exists, the route doesn't until alkfs lands). The feature exists, the route doesn't until alkfs lands). The
server-side rows already landed in wave 2; this lands the client server-side rows already landed in wave 2; this lands the client
mirror so both directions are contract-tested. mirror so both directions are contract-tested.
- **Contract rows through the server double, per build** — the - **Contract rows through the server double, per build**, with the
`w4-dial` fake-`sshd` vehicle grown with the op-row surfaces: `w4-dial` fake-`sshd` vehicle grown with the op-row surfaces:
featured + working rows (exec exit-status, pty window-change featured + working rows (exec exit-status, pty window-change
ordering, direct-tcpip pump, git exec round trip), decline rows ordering, direct-tcpip pump, git exec round trip), decline rows
(server-rejected opens, the sftp decline, off-Unix streamlocal), (server-rejected opens, the sftp decline, off-Unix streamlocal),
the connection-stays-usable row after each decline class. The the connection-stays-usable row after each decline class. The
real-tool smoke posture decided + recorded (a real `sshd` exec is real-tool smoke posture decided + recorded (a real `sshd` exec is
cheap where tooling permits — check-gated like the wave-3 smokes; cheap where tooling permits, check-gated like the wave-3 smokes;
else named wave-5 e2e territory explicitly). else named wave-5 e2e territory explicitly).
## Acceptance Criteria ## Acceptance Criteria
@@ -83,15 +83,15 @@ mechanics are the body of knowledge this task lands in code:
- docs/architecture/client.md §Op rows (the table), §Correctness - docs/architecture/client.md §Op rows (the table), §Correctness
facts, §What the crate does *not* include (job supervision is facts, §What the crate does *not* include (job supervision is
assembly work — the crate gives channels + exit semantics) assembly work; the crate gives channels + exit semantics)
- docs/architecture/mapping.md §The exit derivation contract (the - docs/architecture/mapping.md §The exit derivation contract (the
mirror the client consumes), docs/architecture/decisions/003-feature-graph.md mirror the client consumes), docs/architecture/decisions/003-feature-graph.md
(the feature rows), docs/architecture/decisions/006-mapping-bridge-and-dispatch.md (the feature rows), docs/architecture/decisions/006-mapping-bridge-and-dispatch.md
(exec-only git, both directions) (exec-only git, both directions)
- docs/research/poc-3-mapping-bakeoff.md §D.3 (the client mechanics — - docs/research/poc-3-mapping-bakeoff.md §D.3 (the client mechanics,
the body of knowledge), docs/research/poc-1-auth-bridge.md §6 (the the body of knowledge), docs/research/poc-1-auth-bridge.md §6 (the
open-call `Err` semantics), docs/research/reverse-runner.md (the open-call `Err` semantics), docs/research/reverse-runner.md (the
per-connection surface table — the consumer evidence) per-connection surface table, the consumer evidence)
- src/git_map.rs (the grammar reused), src/tty_map.rs (the exit-chunk - src/git_map.rs (the grammar reused), src/tty_map.rs (the exit-chunk
semantics consumed), russh 0.65 `client::Handle` (`channel_open_session` semantics consumed), russh 0.65 `client::Handle` (`channel_open_session`
/ `channel_open_direct_tcpip` / `channel_open_direct_streamlocal`) / `channel_open_direct_tcpip` / `channel_open_direct_streamlocal`)
@@ -101,7 +101,8 @@ mechanics are the body of knowledge this task lands in code:
### What landed (2026-10-29) ### What landed (2026-10-29)
- `src/client_ops.rs` (new, every build; rows feature-gated inside): - `src/client_ops.rs` (new, every build; rows feature-gated inside):
the op rows — the family op → vanilla translation surfaces, all on the op rows, the family op → vanilla translation surfaces, all
on
`SshConnection` (`dial`'s handle; sibling-module method impls over `SshConnection` (`dial`'s handle; sibling-module method impls over
the crate accessors `russh_handle()` / `budgets()`): the crate accessors `russh_handle()` / `budgets()`):
- *session*: `exec` (pipe), `pty_exec` (`ssh -t`), `shell(Option< - *session*: `exec` (pipe), `pty_exec` (`ssh -t`), `shell(Option<
@@ -110,30 +111,30 @@ mechanics are the body of knowledge this task lands in code:
parity) → `RemoteSession`, the **family chunk plane** (§below). parity) → `RemoteSession`, the **family chunk plane** (§below).
- *tunnel*: `tunnel_direct(host, port)` / - *tunnel*: `tunnel_direct(host, port)` /
`tunnel_direct_unix(path)` → `TunnelChannel` (a plain byte `tunnel_direct_unix(path)` → `TunnelChannel` (a plain byte
duplex — the alksocks `DialFn` composes it; the `-D` leg's duplex, which the alksocks `DialFn` composes; the `-D` leg's
composition needs no dependency, ADR-003 §1a). composition needs no dependency, ADR-003 §1a).
- *git*: `git_service(alkgit::Service, repo_id)` → - *git*: `git_service(alkgit::Service, repo_id)` →
`GitPackSession` (raw pack duplex + stderr duplex + the `GitPackSession` (raw pack duplex + stderr duplex + the
consumed-exit oneshot). consumed-exit oneshot).
- *every build*: `sftp()` → `Err(OpError::SftpNotServed)` (the - *every build*: `sftp()` → `Err(OpError::SftpNotServed)` (the
`Ok` type is `Infallible` — the feature ships but the route `Ok` type is `Infallible`, so the feature ships but the route
doesn't, OQ-13, now tested in both the featureless and the doesn't, OQ-13, now tested in both the featureless and the
featured build). featured build).
- `src/exit_code.rs` (new, extracted from `tty_map.rs` — the wave's - `src/exit_code.rs` (new, extracted from `tty_map.rs`, the wave's
structural edit): the derivation contract's machinery structural edit): the derivation contract's machinery
(`SIGNAL_NAMES` / `ExitReport` / `derive_exit` / (`SIGNAL_NAMES` / `ExitReport` / `derive_exit` /
`sig_from_name` / `sig_control_name`) is now **two-direction `sig_from_name` / `sig_control_name`) is now **two-direction
territory** — the module gained the client arm's territory**; the module gained the client arm's
`signal_code_from_name` (the wire `exit-signal` name → the family `signal_code_from_name` (the wire `exit-signal` name → the family
`−signum` code, `−1` for the unmapped/the sentinel collision) and `−signum` code, `−1` for the unmapped/the sentinel collision) and
the module docs carry the client arm's rules as the same the module docs carry the client arm's rules as the same
contract's inverse. `tty_map.rs` consumes it (no fork anywhere); contract's inverse. `tty_map.rs` consumes it (no fork anywhere);
`handler.rs`'s signal row follows the moved path. `handler.rs`'s signal row follows the moved path.
- `src/error.rs`: [`OpError`] — the op rows' closed taxonomy - `src/error.rs`: [`OpError`], the op rows' closed taxonomy
(`OpenDecline(OpenDeclineReason)` — the *family-mirrored* copy of (`OpenDecline(OpenDeclineReason)`, the *family-mirrored* copy of
the typed open's wire class, the typed open's wire class,
`Other{code,reason}` verbatim, nothing collapsed / `Other{code,reason}` verbatim, nothing collapsed /
`RequestRefused` (the `wait()`-drain's `Failure` reply — POC-3 `RequestRefused` (the `wait()`-drain's `Failure` reply, as POC-3
§D.3's not-an-`Err` fact surfaced as the op's decline class) / §D.3's not-an-`Err` fact surfaced as the op's decline class) /
`ChannelEnded` (the drain saw the channel's end; nothing `ChannelEnded` (the drain saw the channel's end; nothing
fabricated) / `CommandTooLong` (the exec-length budget's fabricated) / `CommandTooLong` (the exec-length budget's
@@ -141,14 +142,14 @@ mechanics are the body of knowledge this task lands in code:
validation gate mirrored, pre-wire) / `UnixUnavailable` (the OQ-11 validation gate mirrored, pre-wire) / `UnixUnavailable` (the OQ-11
gate, pre-wire) / `SftpNotServed` (the placeholder) / gate, pre-wire) / `SftpNotServed` (the placeholder) /
`Wire(russh::Error)` (transparent); the `From<russh::Error>` maps `Wire(russh::Error)` (transparent); the `From<russh::Error>` maps
the typed open's `ChannelOpenFailure` into the family mirror — the typed open's `ChannelOpenFailure` into the family mirror,
one conversion point, unit rows pin every class). with one conversion point, unit rows pin every class).
- `src/dispatch.rs`: the shared grammar's **construction arm** — - `src/dispatch.rs`: the shared grammar's **construction arm**,
`build_git_exec_command(route, repo_id)` beside `parse_exec`, with `build_git_exec_command(route, repo_id)` beside `parse_exec`, with
unit rows proving the construction round-trips the parse for both unit rows proving the construction round-trips the parse for both
services (the grammar's gate is the `Service` *type*: the services (the grammar's gate is the `Service` *type*: the
construction arm can emit no other token, so the client rows are construction arm can emit no other token, so the client rows are
structurally unreachable by `unknown-service`/`service-only` — structurally unreachable by `unknown-service`/`service-only`,
recorded). recorded).
- `src/client.rs`: the crate accessors (`russh_handle`/`budgets`, - `src/client.rs`: the crate accessors (`russh_handle`/`budgets`,
feature-gated; no public-surface change); `src/lib.rs`: the feature-gated; no public-surface change); `src/lib.rs`: the
@@ -156,37 +157,37 @@ mechanics are the body of knowledge this task lands in code:
(`RemoteSession` / `TunnelChannel` / `GitPackSession`, (`RemoteSession` / `TunnelChannel` / `GitPackSession`,
`OpError`/`OpenDeclineReason` public). `OpError`/`OpenDeclineReason` public).
- `tests/common/sshd_double.rs`: the fake `sshd` grown with the - `tests/common/sshd_double.rs`: the fake `sshd` grown with the
op-row script — the session requests' reply knob, the scripted op-row script, with the session requests' reply knob, the scripted
program (stdout/stderr/exit — `exit-then-close`, the vanilla program (stdout/stderr/exit in the `exit-then-close` vanilla
discipline; a no-output/no-exit script launches *nothing*, keeping discipline; a no-output/no-exit script launches *nothing*, keeping
interactive sessions open), the direct opens' echo-or-reject interactive sessions open), the direct opens' echo-or-reject
behavior, a counted-refusal knob for the behavior, a counted-refusal knob for the
stays-usable-after-a-decline rows, and the observation surface stays-usable-after-a-decline rows, and the observation surface
(`SshdObserved`: recorded execs/ptys/windows/signals — the wire (`SshdObserved`: recorded execs/ptys/windows/signals, the wire
translations' witnesses); `tests/client_ops.rs`: the contract rows translations' witnesses); `tests/client_ops.rs`: the contract rows
(below). (below).
### Decided + recorded ### Decided + recorded
1. **The family plane (the session rows) is the alktty chunk framing 1. **The family plane (the session rows) is the alktty chunk framing
in both directions** — the exact plane the server direction's in both directions**, the exact plane the server direction's
bridge speaks, consumed not re-derived: stdin chunks + `CTRL_IN` bridge speaks, consumed not re-derived: stdin chunks + `CTRL_IN`
(sentinel/`Resize`/`Signal`/`Eof`) toward the wire; stdout/stderr (sentinel/`Resize`/`Signal`/`Eof`) toward the wire; stdout/stderr
chunks + the **in-band exit chunk, last** toward the hub. The chunks + the **in-band exit chunk, last** toward the hub. The
exit ride is the family exit-currency (`ControlMessage::Exit`'s exit ride is the family exit-currency (`ControlMessage::Exit`'s
`i32`): wire `ExitStatus(N)` → `N`, wire `ExitSignal(name)` → `i32`): wire `ExitStatus(N)` → `N`, wire `ExitSignal(name)` →
`−signum` via the table's inverse read (unmapped names ride the `−signum` via the table's inverse read (unmapped names ride the
`−1` sentinel — including a genuine SIGHUP, the same collision `−1` sentinel, including a genuine SIGHUP, the same collision
rule 3 consumes server-side). A session ending with no report rule 3 consumes server-side). A session ending with no report
delivers no exit chunk (the no-exit-chunk close family's mirror). delivers no exit chunk (the no-exit-chunk close family's mirror).
2. **The exec/pty/git rows never `into_stream()`; the tunnel rows 2. **The exec/pty/git rows never `into_stream()`; the tunnel rows
never wait.** The mechanics facts pin the *shapes*: the never wait.** The mechanics facts pin the *shapes*: the
conversion's reader swallows exactly the non-`Data` messages the conversion's reader swallows exactly the non-`Data` messages the
exit reports ride on, so an exit-consuming row is structurally a exit reports ride on, so an exit-consuming row is structurally a
**split-halves wait-pump** (`Channel::split()` + spawned drain of **split-halves wait-pump** (`Channel::split()` plus a spawned drain
`wait()` — the same shape `w4-git-exit-close` pinned server-side; of `wait()`, the same shape `w4-git-exit-close` pinned server-side);
the write half stays alive, so the fire-and-forget the write half stays alive, so the fire-and-forget
`window_change`/`signal` rows ride mid-session — the ordering `window_change`/`signal` rows ride mid-session; the ordering
fact's constraint, that controls precede `into_stream()`, is fact's constraint, that controls precede `into_stream()`, is
respected by *construction* across both row kinds). The tunnel respected by *construction* across both row kinds). The tunnel
rows go typed-open → straight `into_stream()`, no queue rows go typed-open → straight `into_stream()`, no queue
@@ -197,7 +198,7 @@ mechanics are the body of knowledge this task lands in code:
disambiguation** (`ChunkReader::read_chunk`, not disambiguation** (`ChunkReader::read_chunk`, not
`tty_map::read_bridge_frame`): that plane's chunk types are `tty_map::read_bridge_frame`): that plane's chunk types are
stdin `0` / `CTRL_IN` `3`; a negotiation-error frame never exists stdin `0` / `CTRL_IN` `3`; a negotiation-error frame never exists
toward the door — and a `0x00`-leading read there is a legitimately toward the door, and a `0x00`-leading read there is a legitimately
valid zero-length stdin chunk, so `read_bridge_frame`'s valid zero-length stdin chunk, so `read_bridge_frame`'s
`0x00`-disambiguation (whose home is the alktty→hub direction, `0x00`-disambiguation (whose home is the alktty→hub direction,
where error frames live) would misparse it. The wave-3 harness's where error frames live) would misparse it. The wave-3 harness's
@@ -211,22 +212,22 @@ mechanics are the body of knowledge this task lands in code:
write failure); the *input* pump closes the wire channel on it. write failure); the *input* pump closes the wire channel on it.
stdin-EOF (the sentinel, a `CTRL_IN` Eof, or the hub's input half stdin-EOF (the sentinel, a `CTRL_IN` Eof, or the hub's input half
dropping) sends the wire EOF *once* and parks the pump in the dropping) sends the wire EOF *once* and parks the pump in the
idle-for-kill state (the session keeps pumping — stdin closure idle-for-kill state (the session keeps pumping; stdin closure
never ends the session). Dropping the surface (both halves) closes never ends the session). Dropping the surface (both halves) closes
the channel — wire-clean, nothing dangling outside the dial's the channel, wire-clean, nothing dangling outside the dial's
own wall clock. own wall clock.
5. **The launch drains are message-capped** (`DRAIN_MESSAGE_CAP`, 5. **The launch drains are message-capped** (`DRAIN_MESSAGE_CAP`,
1024): a peer answering only with non-reply messages cannot loop 1024): a peer answering only with non-reply messages cannot loop
the drain unboundedly (bounded resources; the drain's idle waits the drain unboundedly (bounded resources; the drain's idle waits
are bounded by the dial's always-set inactivity timeout). A are bounded by the dial's always-set inactivity timeout). A
refusal closes the channel behind itself (`drain_launch_request` refusal closes the channel behind itself (`drain_launch_request`,
— the row owned it; never half-serves). which the row owned; never half-serves).
6. **The pty translation**: family `TerminalParamsWire` → wire 6. **The pty translation**: family `TerminalParamsWire` → wire
`pty_request` verbatim (term/cols/rows/pixels, `term: None` → the `pty_request` verbatim (term/cols/rows/pixels, `term: None` → the
empty-terminal-string value; **the modes JSON never rides the empty-terminal-string value; **the modes JSON never rides the
wire** — always the empty set, the OQ-44 reserved posture wire**, always the empty set per the OQ-44 reserved posture
mirrored: the door never invents wire terminal modes); a refused mirrored: the door never invents wire terminal modes); a refused
pty request refuses the whole row (never a pty-less fall) — the pty request refuses the whole row (never a pty-less fall), the
never-half-capability row. never-half-capability row.
7. **The git row's gates before any wire op** (the shared grammar's 7. **The git row's gates before any wire op** (the shared grammar's
verdicts): `validate_repo_id` → `InvalidRepoId`; the built verdicts): `validate_repo_id` → `InvalidRepoId`; the built
@@ -236,7 +237,7 @@ mechanics are the body of knowledge this task lands in code:
stderr rides a split raw duplex (the hub may drop it); the pack stderr rides a split raw duplex (the hub may drop it); the pack
duplex is the protocol's own byte plane, untouched. duplex is the protocol's own byte plane, untouched.
8. **The tunnel rows' originator**: the door's label 8. **The tunnel rows' originator**: the door's label
(`"alk-ssh"`, port 0) — nothing invented, nothing leaked; peer (`"alk-ssh"`, port 0): nothing invented, nothing leaked; peer
logs name the door (recorded; a real originator would be the logs name the door (recorded; a real originator would be the
assembly's local-listener fact, and the `DialFn` shape carries assembly's local-listener fact, and the `DialFn` shape carries
the target only). the target only).
@@ -246,7 +247,7 @@ mechanics are the body of knowledge this task lands in code:
this direction: the always-on wall clock/keepalives, the russh this direction: the always-on wall clock/keepalives, the russh
windows, the bounded duplexes/mpscs, the drain cap. windows, the bounded duplexes/mpscs, the drain cap.
10. **No crate events on the client op rows**: the outcomes ride the 10. **No crate events on the client op rows**: the outcomes ride the
typed surfaces (`Result`/channels) — the decline* event typed surfaces (`Result`/channels), as the decline* event
vocabulary takes a wire channel id, and a *client-side* declined vocabulary takes a wire channel id, and a *client-side* declined
open has none to name (the typed open carries no id back); open has none to name (the typed open carries no id back);
the events remain the server-direction (and dial-skeleton) rows' the events remain the server-direction (and dial-skeleton) rows'
@@ -255,18 +256,18 @@ mechanics are the body of knowledge this task lands in code:
11. **The smoke posture** (decided + recorded, per the task's item): 11. **The smoke posture** (decided + recorded, per the task's item):
the session rows' cheap real-stack smoke is the the session rows' cheap real-stack smoke is the
alkssh-client-op-row ↔ alkssh-server loopback (the client exec alkssh-client-op-row ↔ alkssh-server loopback (the client exec
row against the server direction's *real* session rows — both row against the server direction's *real* session rows, with both
chunk-bridge arms over one live wire), landed as the row chunk-bridge arms over one live wire), landed as the row
`smoke_client_exec_row_against_the_server_directions_session_rows` `smoke_client_exec_row_against_the_server_directions_session_rows`
(gated on `sh` like the wave-3 smokes, skipped-with-reason when (gated on `sh` like the wave-3 smokes, skipped-with-reason when
absent). A **real-vanilla-`sshd`** dial smoke is wave-5 e2e absent). A **real-vanilla-`sshd`** dial smoke is wave-5 e2e
territory explicitly — nothing silently dropped. territory explicitly, with nothing silently dropped.
### Test rows (`tests/client_ops.rs`, one per decline/contract class; per-build binaries) ### Test rows (`tests/client_ops.rs`, one per decline/contract class; per-build binaries)
- *every build*: the sftp placeholder's decline (before any wire op, - *every build*: the sftp placeholder's decline (before any wire op,
no channel opened at the peer) + the featured-build variant (under no channel opened at the peer) + the featured-build variant (under
`sftp`) — the connection-stays-usable close on both. `sftp`), and the connection-stays-usable close on both.
- *session* (10 rows + the smoke): the exec family-plane row - *session* (10 rows + the smoke): the exec family-plane row
(command rides the wire verbatim; stdout/stderr chunks distinct; (command rides the wire verbatim; stdout/stderr chunks distinct;
the exit chunk in-band, last; the stdin sentinel = wire EOF), the the exit chunk in-band, last; the stdin sentinel = wire EOF), the
@@ -277,7 +278,7 @@ mechanics are the body of knowledge this task lands in code:
rows (CTRL_IN Resize → wire `window_change`; Signal → wire rows (CTRL_IN Resize → wire `window_change`; Signal → wire
`signal`, name verbatim), the exit-signal mirror row (terminal `signal`, name verbatim), the exit-signal mirror row (terminal
`exit-signal("TERM")` → the family code `−15` in-band), the `exit-signal("TERM")` → the family code `−15` in-band), the
never-half-capability row (a refused pty refuses the whole row — never-half-capability row (a refused pty refuses the whole row, so
no exec behind it ever rode the wire), the server-rejected-open no exec behind it ever rode the wire), the server-rejected-open
decline row (`OpenDeclined(Prohibited)`) with the decline row (`OpenDeclined(Prohibited)`) with the
stays-usable-after-the-decline launch on the same connection, and stays-usable-after-the-decline launch on the same connection, and
@@ -286,8 +287,8 @@ mechanics are the body of knowledge this task lands in code:
round-trips; `poll_shutdown` EOF rides), the round-trips; `poll_shutdown` EOF rides), the
reject-maps-the-class + stays-usable row (`ConnectFailed` mirror; reject-maps-the-class + stays-usable row (`ConnectFailed` mirror;
the streamlocal twin then opens on the same connection on-Unix; the streamlocal twin then opens on the same connection on-Unix;
`#[cfg(not(unix))]`'s OQ-11 decline row compiles off-Unix only — `#[cfg(not(unix))]`'s OQ-11 decline row compiles off-Unix only, per the
the universal-compile/clean-decline gate mirrored from the server universal-compile/clean-decline gate mirrored from the server
side), the twin's on-Unix pump served in the same row. side), the twin's on-Unix pump served in the same row.
- *git*: the grammar's construction riding the wire verbatim + the - *git*: the grammar's construction riding the wire verbatim + the
pack duplex pump + the `exit-status(0)` consumed into the family pack duplex pump + the `exit-status(0)` consumed into the family
@@ -300,7 +301,7 @@ mechanics are the body of knowledge this task lands in code:
### Verification ### Verification
`cargo test` (default), per-feature (`--features {session, git, `cargo test` (default), per-feature (`--features {session, git,
tunnel, sftp}`), pairs, and `--all-features` — all 15 test binaries tunnel, sftp}`), pairs, and `--all-features`, all 15 test binaries
green in the full builds (the client_ops binary: 18 rows under green in the full builds (the client_ops binary: 18 rows under
`--all-features`, the per-build sets: session 12 / git 5 / tunnel 3 `--all-features`, the per-build sets: session 12 / git 5 / tunnel 3
/ featureless 1); `cargo clippy --all-features --all-targets` and / featureless 1); `cargo clippy --all-features --all-targets` and
@@ -316,7 +317,7 @@ typed-open decline's one-to-one mirror). The session rows give the
hub the alktty chunk plane in both directions with the exit hub the alktty chunk plane in both directions with the exit
consumed in-band as the family exit chunk (the derivation contract's consumed in-band as the family exit chunk (the derivation contract's
client arm, extracted into the shared two-direction `exit_code.rs` client arm, extracted into the shared two-direction `exit_code.rs`
module — table and rules never forked); the tunnel rows are the module; table and rules never forked there); the tunnel rows are the
typed `direct-tcpip`/`direct-streamlocal` opens into raw byte typed `direct-tcpip`/`direct-streamlocal` opens into raw byte
duplexes (rejects mirror to `OpenDeclineReason`, the connection duplexes (rejects mirror to `OpenDeclineReason`, the connection
stays usable, row-pinned per decline class); the git row builds the stays usable, row-pinned per decline class); the git row builds the
+50 -46
View File
@@ -1,6 +1,6 @@
--- ---
id: w4-reverse-listen id: w4-reverse-listen
name: Wave 4 — the client -R leg (forward requests + the acceptor registry) name: "Wave 4: the client -R leg (forward requests + the acceptor registry)"
status: completed status: completed
depends_on: [w4-op-rows] depends_on: [w4-op-rows]
scope: moderate scope: moderate
@@ -15,44 +15,45 @@ Land the client direction's `-R` leg (`client.md` §Op rows row 4 +
§The acceptor): the hub asks the vanilla server to listen, then §The acceptor): the hub asks the vanilla server to listen, then
**accepts the server-initiated `forwarded-tcpip` opens** and routes **accepts the server-initiated `forwarded-tcpip` opens** and routes
each to the family handler. The acceptor row is unavoidable and its each to the family handler. The acceptor row is unavoidable and its
disposition is ours (reverse-runner fact 1) — russh's client default disposition is ours (reverse-runner fact 1), because russh's client default
is accept-and-drop, a half-capability the family forbids; the is accept-and-drop, a half-capability the family forbids; the
`w4-dial` skeleton pins the fail-closed default, this task grows the `w4-dial` skeleton pins the fail-closed default, this task grows the
registry that serves: registry that serves:
- **The forward request rows** — `tcpip_forward(address, port) -> - **The forward request rows**: `tcpip_forward(address, port) ->
bound` (russh's client `Handle` returns the actually-bound `u32`; bound` (russh's client `Handle` returns the actually-bound `u32`;
`port = 0` → the server-picked number surfaced — the lifecycle `port = 0` → the server-picked number surfaced, the lifecycle
primitive `client.md` §Lifecycle pins), `cancel_tcpip_forward`, primitive `client.md` §Lifecycle pins), `cancel_tcpip_forward`,
`streamlocal_forward` + `cancel_streamlocal_forward` with the `streamlocal_forward` + `cancel_streamlocal_forward` with the
**runtime-Unix gate** (OQ-11 symmetry: universal compile, clean **runtime-Unix gate** (OQ-11 symmetry: universal compile, clean
off-Unix decline — the dial-call-witness posture the server side off-Unix decline, the dial-call-witness posture the server side
landed). landed).
- **The acceptor registry** — the assembly-mutable, per-channel-type - **The acceptor registry**, the assembly-mutable, per-channel-type
registry the client Handler routes server-initiated opens through: registry the client Handler routes server-initiated opens through:
served **only** under a registered acceptor **for that type and served **only** under a registered acceptor **for that type and
under its feature** (`forwarded-tcpip` / `forwarded-streamlocal` under its feature** (`forwarded-tcpip` / `forwarded-streamlocal`
under `tunnel` is the real case); unregistered/unfeatured accepted under `tunnel` is the real case); unregistered/unfeatured accepted
opens **declined cleanly** — never silently dropped after accept opens **declined cleanly**, never silently dropped after accept
(ADR-003 §4). Decide + record the registry's exact shape (per-type (ADR-003 §4). Decide + record the registry's exact shape (per-type
slots vs a typed map; the acceptor trait signature — what an slots vs a typed map; the acceptor trait signature, naming what an
accepted open hands the assembly: the channel duplex + the open's accepted open hands the assembly: the channel duplex + the open's
originator fields) against the harness's `ForwardedAcceptor` originator fields) against the harness's `ForwardedAcceptor`
vocabulary (`w3-tunnel-listen` landed the first shape client-side vocabulary (`w3-tunnel-listen` landed the first shape client-side
in tests — grow it into the crate's real surface, don't fork it). in tests; grow it into the crate's real surface, don't fork it).
- **Late-install across a live connection** (the alkminer - **Late-install across a live connection** (the alkminer
`install_forward_sink` dynamic — reverse-runner fact 2): acceptors `install_forward_sink` dynamic, reverse-runner fact 2): acceptors
install/uninstall while the connection is up; consumers turn install/uninstall while the connection is up; consumers turn
capabilities on/off per connection. An open arriving before its capabilities on/off per connection. An open arriving before its
acceptor installs is declined (fail-closed at arrival time — acceptor installs is declined (fail-closed at arrival time, with a decide-and-record;
decide + record; no buffering of unclaimed opens). no buffering of unclaimed opens).
- **The data leg** — a registered acceptor's open: the channel duplex - **The data leg**, a registered acceptor's open: the channel duplex
handed to the assembly handler (the pump is the acceptor's, per the handed to the assembly handler (the pump is the acceptor's, per the
family handler shape); bounded bookkeeping (no unbounded registry, family handler shape); bounded bookkeeping (no unbounded registry,
no unbounded per-connection listener map — the `Limits` cap no unbounded per-connection listener map, with the `Limits` cap
relation decided + recorded, the server-side listener-counter relation decided + recorded, the server-side listener-counter
posture as the template). posture as the template).
- **Contract rows through the server double, per build** — the fake - **Contract rows through the server double, per build**, with the
fake
`sshd` grown with the server-initiated-open surfaces: featured + `sshd` grown with the server-initiated-open surfaces: featured +
registered → served; featured + unregistered → clean decline; registered → served; featured + unregistered → clean decline;
unfeatured → clean decline (per-build matrices, the symmetry rule); unfeatured → clean decline (per-build matrices, the symmetry rule);
@@ -61,7 +62,7 @@ registry that serves:
off-Unix streamlocal rows; the bounded-bookkeeping rows. The off-Unix streamlocal rows; the bounded-bookkeeping rows. The
real-tool smoke posture decided + recorded (a real `ssh -R`-shaped real-tool smoke posture decided + recorded (a real `ssh -R`-shaped
round trip against the double is the in-process form; real-`sshd` round trip against the double is the in-process form; real-`sshd`
`-R` is wave-5 e2e — never silently dropped). `-R` is wave-5 e2e, never silently dropped).
## Acceptance Criteria ## Acceptance Criteria
@@ -72,7 +73,7 @@ registry that serves:
- [x] The acceptor registry lands (shape decided + recorded): served - [x] The acceptor registry lands (shape decided + recorded): served
only under a registered acceptor under `tunnel`; only under a registered acceptor under `tunnel`;
unregistered/unfeatured server-initiated opens declined cleanly unregistered/unfeatured server-initiated opens declined cleanly
(never accept-and-drop) — wire rows per build (never accept-and-drop), wire rows per build
- [x] Late-install works across a live connection; a pre-install - [x] Late-install works across a live connection; a pre-install
arrival is declined (decided + recorded, no unclaimed buffering) arrival is declined (decided + recorded, no unclaimed buffering)
- [x] The data leg hands accepted opens to the assembly handler; - [x] The data leg hands accepted opens to the assembly handler;
@@ -87,16 +88,16 @@ registry that serves:
- docs/architecture/client.md §Op rows (row 4), §The acceptor - docs/architecture/client.md §Op rows (row 4), §The acceptor
(fail-closed client-side), §Lifecycle primitives (fail-closed client-side), §Lifecycle primitives
- docs/architecture/decisions/003-feature-graph.md (§4 the - docs/architecture/decisions/003-feature-graph.md (§4 the
client-side fail-closed acceptor — the rule this task implements), client-side fail-closed acceptor, the rule this task implements),
docs/architecture/decisions/006-mapping-bridge-and-dispatch.md docs/architecture/decisions/006-mapping-bridge-and-dispatch.md
(route 1, both legs) (route 1, both legs)
- docs/research/reverse-runner.md (facts 1–2 — the acceptor row's - docs/research/reverse-runner.md (facts 1–2, the acceptor row's
disposition is ours; the late-install dynamic; lesson 1), docs/research/russh.md disposition is ours; the late-install dynamic; lesson 1), docs/research/russh.md
§Client surface (the `server_channel_open_*` hook set; the §Client surface (the `server_channel_open_*` hook set; the
`tcpip_forward -> u32` signature) `tcpip_forward -> u32` signature)
- docs/research/poc-3-mapping-bakeoff.md §C.4 (the fabric recipe — - docs/research/poc-3-mapping-bakeoff.md §C.4 (the fabric recipe,
the family-side half the accepted opens feed) the family-side half the accepted opens feed)
- tests/common/harness.rs (`ForwardedAcceptor` — the vocabulary to - tests/common/harness.rs (`ForwardedAcceptor`, the vocabulary to
grow, not fork), src/tunnel_map.rs (the server-side listener grow, not fork), src/tunnel_map.rs (the server-side listener
bookkeeping posture as the template) bookkeeping posture as the template)
@@ -107,36 +108,38 @@ registry that serves:
Implementation decisions (also recorded in `src/client_accept.rs`'s Implementation decisions (also recorded in `src/client_accept.rs`'s
module docs and `client.md` §The acceptor's landing block): module docs and `client.md` §The acceptor's landing block):
- **Registry shape — per-type fixed slots, not a typed map**: the - **Registry shape (per-type fixed slots, not a typed map)**: the
client-direction registry-servable set is closed (exactly the two client-direction registry-servable set is closed (exactly the two
`-R` data-leg types under `tunnel`); one acceptor trait per type `-R` data-leg types under `tunnel`); one acceptor trait per type
(`TcpipForwardedAcceptor` / `StreamlocalForwardedAcceptor`), the (`TcpipForwardedAcceptor` / `StreamlocalForwardedAcceptor`), the
payload an accepted open hands the assembly: the channel duplex payload an accepted open hands the assembly: the channel duplex
(family-typed — the `-L` rows' `TunnelChannel`, one type both legs (family-typed, the `-L` rows' `TunnelChannel` that both legs
ride) + the open's echo/originator fields — the harness ride) plus the open's echo/originator fields, the harness
`ForwardedAcceptor` vocabulary grown, not forked. Install is `ForwardedAcceptor` vocabulary grown and not forked. Install is
**refuse-never-replace** (`AcceptorHeld` — the server-side **refuse-never-replace** (`AcceptorHeld`, the server-side
`ListenRouter::register` posture mirrored); uninstall is explicit `ListenRouter::register` posture mirrored); uninstall is explicit
and reports honestly. A new module `src/client_accept.rs` (one and reports honestly. A new module `src/client_accept.rs` (one
module per file; the rows' method bodies live beside the registry). module per file; the rows' method bodies live beside the registry).
- **The listen rows gate on the registered acceptor** (the - **The listen rows gate on the registered acceptor** (the
issuing-side disposition — a listen whose data leg cannot serve is issuing-side disposition, so a listen whose data leg cannot serve
is
never asked for; install-first recorded order; never asked for; install-first recorded order;
`OpError::NoAcceptor` pre-wire). The cancel forms do *not* gate on `OpError::NoAcceptor` pre-wire). The cancel forms do *not* gate on
the acceptor (teardown must work after an uninstall). the acceptor (teardown must work after an uninstall).
- **Bound-port mapping** (recorded wire fact at this pin): russh's - **Bound-port mapping** (recorded wire fact at this pin): russh's
client `tcpip_forward` reply carries a port **only for client `tcpip_forward` reply carries a port **only for
`port = 0` requests** (sshd parity — an explicit request's bound `port = 0` requests** (sshd parity, since an explicit request's bound
port *is* the requested port); `reverse_listen` maps both to the port *is* the requested port); `reverse_listen` maps both to the
actually-bound number it returns. actually-bound number it returns.
- **Bounded bookkeeping — the cap relation**: two dedicated - **Bounded bookkeeping (the cap relation)**: two dedicated
`ChannelCap` counters, each against the same `ChannelCap` counters, each against the same
`Limits::max_concurrent_channels` ceiling (the server-side `Limits::max_concurrent_channels` ceiling (the server-side
listener/ListenerCap posture mirrored): (a) the per-connection listener/ListenerCap posture mirrored): (a) the per-connection
**reverse-listen count** — admitted at the listen rows, released at **reverse-listen count**, admitted at the listen rows, released
confirmed cancels and the refused/wire-error paths (never a map — at
the outstanding listens live at the peer); (b) the **accepted-open confirmed cancels and the refused/wire-error paths (never a map,
handoff count** — admitted in the dial handler's open hook before since the outstanding listens live at the peer); (b) the **accepted-open
handoff count**, admitted in the dial handler's open hook before
the accept, released when the acceptor's `accept` call completes in the accept, released when the acceptor's `accept` call completes in
the spawned handoff task; a full counter declines the arrival the spawned handoff task; a full counter declines the arrival
(`cap-exceeded`, event vocabulary). No accepted-open ledger: the (`cap-exceeded`, event vocabulary). No accepted-open ledger: the
@@ -146,14 +149,15 @@ module docs and `client.md` §The acceptor's landing block):
bind is the platform-bound op; `forwarded-streamlocal` *ingestion* bind is the platform-bound op; `forwarded-streamlocal` *ingestion*
is platform-neutral byte pumping, hook universal compile). is platform-neutral byte pumping, hook universal compile).
- **Pre-existing wart fixed**: `client_ops.rs`'s test mod gated - **Pre-existing wart fixed**: `client_ops.rs`'s test mod gated
`family_exit_code` under a `tunnel`-inclusive cfg — the `family_exit_code` under a `tunnel`-inclusive cfg, so the
`--features tunnel` build did not compile its tests before this `--features tunnel` build did not compile its tests before this
task; the import/test are now gated to the exit-consuming builds. task; the import/test are now gated to the exit-consuming builds.
Surprises: russh's client reply shapes (above, the port-0-only mint) Surprises: russh's client reply shapes (above, the port-0-only mint)
and the `JoinHandle::drop`-is-detach-not-cancel behavior in the and the `JoinHandle::drop`-is-detach-not-cancel behavior in the
double's cancel teardown (first draft removed the entry without double's cancel teardown (first draft removed the entry without
aborting the accept loop — the real listener stayed bound; the double aborting the accept loop, so the real listener stayed bound; the
double
aborts explicitly now). aborts explicitly now).
## Summary ## Summary
@@ -173,49 +177,49 @@ Details in the `Landed` block below.
Landed (`feat(client)`: w4-reverse-listen): Landed (`feat(client)`: w4-reverse-listen):
- **`src/client_accept.rs`** — the acceptor registry - **`src/client_accept.rs`**: the acceptor registry
(`AcceptorRegistry` + the two per-type acceptor traits + the (`AcceptorRegistry` + the two per-type acceptor traits + the
`ForwardedTcpip`/`ForwardedStreamlocal` payloads + `AcceptorHeld`), `ForwardedTcpip`/`ForwardedStreamlocal` payloads + `AcceptorHeld`),
the reverse-listen rows (`reverse_listen` / `reverse_listen_cancel` the reverse-listen rows (`reverse_listen` / `reverse_listen_cancel`
/ the unix pair, with the gates + dedicated counters), and the / the unix pair, with the gates + dedicated counters), and the
accepted-opens' handoff tasks; exports from the crate root under accepted-opens' handoff tasks; exports from the crate root under
`tunnel`. `tunnel`.
- **`src/client.rs`** — the dial wires the (empty) registry + - **`src/client.rs`**: the dial wires the (empty) registry plus
tunnel surface into the handler and the connection tunnel surface into the handler and the connection
(`SshConnection::acceptors` — the live-connection accessor; (`SshConnection::acceptors`, the live-connection accessor;
the listener budget's private field). the listener budget's private field).
- **`src/client_handler.rs`** — the two forwarded hooks route through - **`src/client_handler.rs`**, the two forwarded hooks route through
the registry at arrival time (registered + room → accept + spawned the registry at arrival time (registered + room → accept + spawned
handoff; unregistered/cap-exhausted → clean `Administratively- handoff; unregistered/cap-exhausted → clean `Administratively-
Prohibited` decline with the event vocabulary); every other type Prohibited` decline with the event vocabulary); every other type
keeps the decline-all skeleton; the never-feature rows unchanged. keeps the decline-all skeleton; the never-feature rows unchanged.
- **`src/error.rs`** — `OpError::NoAcceptor` / `ListenRefused` / - **`src/error.rs`**, holding `OpError::NoAcceptor` / `ListenRefused` /
`ListenCapExceeded` + the `RequestDenied → ListenRefused` mapping `ListenCapExceeded` + the `RequestDenied → ListenRefused` mapping
(the listen/cancel forms' only wire decline shape at this pin). (the listen/cancel forms' only wire decline shape at this pin).
- **`tests/common/sshd_double.rs`** — the fake `sshd`'s `-R` server - **`tests/common/sshd_double.rs`**, the fake `sshd`'s `-R` server
half: the four listen/cancel hooks (scripted accept/deny via half: the four listen/cancel hooks (scripted accept/deny via
`DblListen`; an accept binds a **real** socket, mints the real `DblListen`; an accept binds a **real** socket, mints the real
bound port for `port = 0` into the out-param, spawns the accept bound port for `port = 0` into the out-param, spawns the accept
loop — per accepted connection a server-initiated `forwarded-*` loop; per accepted connection a server-initiated `forwarded-*`
open echoing the listen's args + the peer, then the bytes pumped; open echoing the listen's args + the peer, then the bytes pumped;
cancels match by the raw wire strings, tearing the real listener cancels match by the raw wire strings, tearing the real listener
down) + the listen/cancel observations. down) + the listen/cancel observations.
- **`tests/client_reverse_listen.rs`** (`tunnel` build) — the - **`tests/client_reverse_listen.rs`** (`tunnel` build), carrying the
contract rows: listen + data-leg pump round trip (real contract rows: listen + data-leg pump round trip (real
`ssh -R`-shaped), the port-0 mint, the no-acceptor pre-wire `ssh -R`-shaped), the port-0 mint, the no-acceptor pre-wire
declines (listen + open, with the event rows), the late-install declines (listen + open, with the event rows), the late-install
rows (pre-install declined → serve-after; uninstall takes it off), rows (pre-install declined → serve-after; uninstall takes it off),
the cancel rows (teardown observable — reconnect refused; unknown the cancel rows (teardown observable: reconnect refused; unknown
→ `ListenRefused`), the listener-ceiling row (exceeded pre-wire; → `ListenRefused`), the listener-ceiling row (exceeded pre-wire;
cancel recovers), the registry contract through the accessor, and cancel recovers), the registry contract through the accessor, and
the streamlocal unix rows (+ the compiled off-Unix gate row). the streamlocal unix rows (+ the compiled off-Unix gate row).
Per-build matrix: the *unfeatured* builds' wire rows run in every Per-build matrix: the *unfeatured* builds' wire rows run in every
build through `tests/client_dial.rs`'s decline-all row (the build through `tests/client_dial.rs`'s decline-all row (the
surface itself is the compile-time mirror). surface itself is the compile-time mirror).
- **`tests/tunnel_mapping.rs`** — the `client_smoke` module: the - **`tests/tunnel_mapping.rs`**, the `client_smoke` module: the
full-circle alkssh-client ↔ alkssh-server-direction smoke (both full-circle alkssh-client ↔ alkssh-server-direction smoke (both
`-R` legs through the alkcall fabric double over a real TCP `-R` legs through the alkcall fabric double over a real TCP
mount) — the port-0 mint through the server direction's fabric mount), with the port-0 mint through the server direction's fabric
listen + arrival pump into the registered acceptor's duplex, bytes listen + arrival pump into the registered acceptor's duplex, bytes
round-tripped end to end; real-`sshd` `-R` remains wave-5 e2e round-tripped end to end; real-`sshd` `-R` remains wave-5 e2e
(recorded, never silently dropped). (recorded, never silently dropped).
Loaded 100 of 132 files, more files were not shown because too many files have changed in this diff. Show more