Compare 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:
1. **Current State** — what phase the project is in, what's implemented
2. **Architecture Documents** — table linking to each spec doc with status
3. **ADR Table** — every decision with number, title, and status
4. **Open Questions** — link to `open-questions.md`
1. **Current State**: what phase the project is in, what's implemented
2. **Architecture Documents**: a table linking to each spec doc with status
3. **ADR Table**: every decision with number, title, and status
4. **Open Questions**: a link to `open-questions.md`
### Spec Documents
@@ -83,7 +83,7 @@ Accepted | Proposed | Deprecated | Superseded
(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
mark the old one Superseded.
@@ -97,12 +97,12 @@ don't need ADRs.
`open-questions.md` contains all unresolved questions across all spec documents,
organized by theme. Each question has:
- **OQ-ID** (OQ-01, OQ-02, ...) — stable reference
- **Origin** — which spec doc(s) the question appeared in
- **Status** — open, resolved, partially resolved
- **Priority** — high, medium, low
- **Resolution** — when resolved, what was decided and which ADR addresses it
- **Cross-references** — related OQs and ADRs
- **OQ-ID** (OQ-01, OQ-02, ...), the stable reference
- **Origin**: which spec doc(s) the question appeared in
- **Status**: open, resolved, partially resolved
- **Priority**: high, medium, low
- **Resolution**: when resolved, what was decided and which ADR addresses it
- **Cross-references**: related OQs and ADRs
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
@@ -136,7 +136,7 @@ Before writing architecture:
- Understand the problem domain
- Identify constraints and quality attributes
- 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
### 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
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
open questions tracked centrally.
@@ -180,7 +180,7 @@ Before requesting external review:
blocking condition (for `deferred(scope)`) or investigation target
(for `deferred(unclear)`) isn't a *prerequisite* of the thing you're
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
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
2. Create a blocker task in `tasks/architecture/` naming the dependency
(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
@@ -217,12 +217,12 @@ task(
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
terms, circular deferrals
- **Warning**: Should fix — missing cross-references, documents approaching
- **Warning**: Should fix (missing cross-references, documents approaching
split threshold
- **Suggestion**: Consider — minor clarity improvements
- **Suggestion**: Consider (minor clarity improvements
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"
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 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
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
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
decision-making. See ADR-009 §"What this framework is NOT" for the
full rationale.
@@ -282,17 +282,17 @@ Architecture decisions are the architect's, regardless of door type.
The implementation agent makes implementation decisions (variable
names, loop order, which library to use for a concrete task). If a
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
1. **Inline decisions**: DD1, D3, SE2 etc. in spec docs — extract to ADRs
2. **Inline open questions**: Scattered per-doc "Open Questions" sections —
1. **Inline decisions**: DD1, D3, SE2 etc. in spec docs; extract to ADRs
2. **Inline open questions**: Scattered per-doc "Open Questions" sections;
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
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
6. **Missing ADR for a visible choice**: If a reader would ask "why X over Y?",
write an ADR
@@ -311,7 +311,7 @@ reasoning to see its own circular hedges; a fresh context catches them.
## Safe Exit: Deferred Decisions
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
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
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
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.
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
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
types, existing patterns) but the composition — how they fit together
into a coherent shape — isn't clear yet. Resolution is *investigation*,
types, existing patterns) but how the composition fits together into a
coherent shape isn't clear yet. Resolution is *investigation*,
not waiting: work through example use cases, maybe build a POC, maybe
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
type) is still unclear. Forcing a decision in that state produces a
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
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
POC), not *waiting* (for a spec or use case to arrive)
- 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
### 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`
(implies it's decided).
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
spec exists" or "investigation: work through 2+ example git-over-ssh
use cases (clone and push) to see how identity + auth + the
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
unbuffered POST ingestion" not "blocks the http crate." This is the
triage signal that
makes the deferral's urgency visible. If the impact is significant,
the deferral needs to be addressed soon; if it's a future feature,
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
next architecture revision.
@@ -398,10 +398,10 @@ A decision should be `deferred(unclear)` when:
- **Status**: deferred(scope) | deferred(unclear)
- **Door type**: <one-way | two-way>
- **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))
**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
composition isn't clear (deferred(unclear), cite the pieces).>
- **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/`)
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`):
```yaml
---
id: architecture/<slug> # 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
scope: narrow # single | narrow | moderate | broad | system
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
validate`/`list` and every graph command: no error, the task simply
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`,
POC/validation = `research`, decomposition pass = `decomposition`,
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
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
with an escape hatch" is hedging.
- 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)`.
- 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
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.
## When to Redirect
+15 -15
View File
@@ -84,7 +84,7 @@ Check coverage of:
#### 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
fresh context can see the whole picture and spot reasoning that folds
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
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
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.
- "additive, not blocking" — if it's not decided, don't claim it is.
- "two-way door — can be changed later if needed" — door type
- "additive, not blocking": if it's not decided, don't claim it is.
- "two-way door, can be changed later if needed": door type
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.
- "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.
- 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
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
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
blocker needs it.
@@ -136,17 +136,17 @@ Check each deferred OQ:
or do the pieces exist but the shape wasn't synthesized
(`deferred(unclear)` is correct), or do the pieces *and* the shape
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
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)`
only if the information genuinely is missing.
**3. Legitimate deferral.** A deferred OQ where the information
genuinely doesn't exist yet (`deferred(scope)`) or the pieces exist
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 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
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.
- Is it specific? "Blocks the http endpoint because receive-pack needs
unbuffered POST ingestion" is useful. "Blocks the http crate" is
boilerplate.
- 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.
Flag missing impacts fields as **warning**, vague ones as
@@ -260,5 +260,5 @@ section 2"
- Critical issues must block stabilization
- The Decision Quality (F) category is often the highest-value check on
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.
+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
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.
```text
@@ -111,9 +111,9 @@ cargo fmt --check # Format check
For this project, also verify:
- 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
- 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
- Public API is well-documented with `///` doc comments where appropriate
- 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)
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.
### Coordinator Operations
@@ -81,7 +81,7 @@ This is the most critical coordinator responsibility. Follow it exactly:
If merge conflicts occur:
- **Source code conflicts between parallel tasks** that modify the same file:
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.
- **Doc conflicts**: Read both sides and keep the most recent/complete
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
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 —
no separate `git push origin --delete` needed. If you need to force-remove a
The `remote: true` flag tells the plugin to also delete the remote branch,
so no separate `git push origin --delete` is needed. If you need to force-remove a
dirty worktree, add `force: true`.
**Bulk cleanup of merged branches** (useful after completing a generation):
@@ -132,12 +132,12 @@ in this order:
latest main)
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 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.
1. **Read the blocking message carefully.** The agent should have included what
@@ -162,7 +162,7 @@ ignore it.
```
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
- Ambiguous architecture? Ask the user to clarify
- Scope too large? Decompose into smaller tasks
@@ -179,15 +179,15 @@ ignore it.
The `prompt` parameter supports `{{task}}` template substitution. Use it, but
also include:
1. **Task identification** — How to find their task file in `tasks/`
2. **Merge from main** — Tell them to
1. **Task identification**: how to find their task file in `tasks/`
2. **Merge from main**: tell them to
`git fetch origin && git merge origin/main --no-edit` before starting, since
main may have advanced since their worktree was created
3. **Key references** — Which source files and architecture docs to read
4. **Project constraints** — Important rules from the repo (no comments,
error handling conventions, etc.). Point at `AGENTS.md` at the repo root —
it is the authoritative list; don't duplicate the whole list here.
5. **Done signal** — Use `worktree({action: "notify", ...})` when complete
3. **Key references**: which source files and architecture docs to read
4. **Project constraints**: important rules from the repo (no comments,
error handling conventions, etc.). Point at `AGENTS.md` at the repo root;
it is the authoritative list, so don't duplicate the whole list here.
5. **Done signal**: use `worktree({action: "notify", ...})` when complete
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
4. Implement the changes, following all acceptance criteria.
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)
8. Notify: worktree({action: "notify", args: {message: "Task completed: {{task}}. <brief summary>", level: "info"}})
Key project constraints (@alkdev/alkssh):
- 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
opens decline fail-closed (a git-only build serves no shell)
- 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.
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.
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."
}})
# Review tasks — often handle yourself
# Review tasks, often handle yourself
# If level: review, verify the acceptance criteria against the codebase
# 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
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.
Check `worktree({action: "sessions"})` when:
@@ -316,7 +316,7 @@ Use these when:
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
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.
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 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.
### Why Agents Don't Commit Task Files
@@ -404,12 +404,12 @@ When an agent reports blocked or an anomaly fires:
what happened
2. Send guidance via `worktree({action: "message", ...})` if you can help
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
### 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
of changes, validate, and commit. Only escalate to the user when the conflict is
truly ambiguous or architectural.
@@ -422,8 +422,8 @@ After merging and pushing:
```text
worktree({action: "cleanup", args: {action: "remove", pathOrBranch: "feat/<task-name>", remote: true}})
```
The `remote: true` flag handles remote branch deletion automatically — no
separate `git push origin --delete` needed.
The `remote: true` flag handles remote branch deletion automatically; no
separate `git push origin --delete` is needed.
Don't let stale branches accumulate.
+32 -32
View File
@@ -10,7 +10,7 @@ graph.
## Your Environment
**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):**
@@ -60,14 +60,14 @@ auto-injects `workdir` for bash commands when the session is mapped to a
worktree. This means:
```bash
# ✅ CORRECT — workdir is auto-injected
# ✅ CORRECT: workdir is auto-injected
cargo test
# ✅ ALSO CORRECT — explicit workdir still works
# ✅ ALSO CORRECT: explicit workdir still works
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.
## Workflow
@@ -130,7 +130,7 @@ Check each acceptance criterion in the task file.
### 5. Commit and Notify
```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 commit -m "feat(<task-id>): <description>"
git push origin $(git branch --show-current)
@@ -168,7 +168,7 @@ When task becomes untendable:
### Process
1. **Stop** - don't force through
1. **Stop**, don't force through
2. **Notify coordinator** with a detailed blocking message. Include:
- What you were trying to do
- 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"}})
```
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
5. **Exit** - coordinator handles escalation
5. **Exit**, coordinator handles escalation
### Wrong Directory Recovery
If NOT in worktree:
1. **STOP** - no more file changes
1. **STOP**, no more file changes
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)
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
- `memory({tool: "messages", args: {sessionId: "..."}})` — review previous
- `memory({tool: "messages", args: {sessionId: "..."}})`: review previous
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
- `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%
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
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.
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
`RwLock`/`Mutex`, use `unwrap_or_else(|e| e.into_inner())` so a panic in
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`,
`mpsc`) for correlation and subscription channels; `parking_lot` for
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;
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
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
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
wire-callable. No unauthenticated endpoints; every capability the
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.
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
compiles lean. Verify both `cargo test` (default) and
`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,
`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.
## Key Principles
1. **Read first** - understand before implementing
2. **Verify before completing** - all criteria met
3. **Safe exit is okay** - better to block than force failures
4. **Minimal changes** - implement exactly what's needed
5. **Worktree isolation** - never touch files outside your worktree
6. **Communicate** - use `worktree({action: "notify", ...})` to keep coordinator
1. **Read first**: understand before implementing
2. **Verify before completing**: all criteria met
3. **Safe exit is okay**: better to block than force failures
4. **Minimal changes**: implement exactly what's needed
5. **Worktree isolation**: never touch files outside your worktree
6. **Communicate**: use `worktree({action: "notify", ...})` to keep coordinator
informed
+9 -9
View File
@@ -7,32 +7,32 @@ temperature: 0.3
You are the **POC Specialist**, creating proof-of-concepts to validate technical
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
constraints (comments and unwrap are acceptable), and their source is
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
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.
- **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
any alkdev repo). Write findings directly into `docs/research/` on main
when done.
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)
**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.
- 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>`
- 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:
```bash
# ✅ CORRECT — workdir is auto-injected
# ✅ CORRECT: workdir is auto-injected
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.
## 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
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
regularly so work is never stranded locally.
@@ -21,17 +21,17 @@ The workflow:
1. Make the change
2. Verify: `cargo test`, `cargo clippy --all-targets -- -D warnings`,
`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
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
optional. For multi-point changes, use a summary line plus a body with
bullet points and a verification block.
5. `git push origin main`
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
it kept)
@@ -44,7 +44,7 @@ Exceptions — **do not** commit or push without asking:
commit, or skipping 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.
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)
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
to/from alkcall channels. The SSH counterpart of alkhttp's adapter
role — the vanilla-ssh front door for vanilla clients. No binary until
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
an assembly needs one; downstream apps embed the crate and bring their
own deployment. The conventions below apply to all work in `src/` and
`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
or when a non-obvious safety/correctness constraint would otherwise
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
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
`unwrap_or_else(|e| e.into_inner())` so a panic in one operation does
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
the async path. Use `tokio::sync` primitives (`oneshot`, `mpsc`) for
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
channel multiplexing come from russh; the crate's substance is the
glue to/from alkcall and the channel-type mappings. Third-party-
upstream posture: file asks to russh upstream; never silently absorb
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
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
capabilities; a git-only build cannot serve a shell, tunnels, or
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
silently otherwise.
6. **Key reuse, no env vars** — keys reuse the alknet `main` key model
(`russh::keys`, authorized-key/KeySource handling — see
6. **Key reuse, no env vars**: keys reuse the alknet `main` key model
(`russh::keys`, authorized-key/KeySource handling; see
`/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)
(family ADR-003 decision 2). No secret
material on the wire, in plaintext at rest, or in env vars — the
standing family security posture (family ADR-002 decision 5).
material on the wire, in plaintext at rest, or in env vars; this is
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
`AccessControl::check(peer_identity)`; ops with
`Visibility::Internal` are never wire-callable. There is no
unauthenticated capability; the fail-closed feature declines are part
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
published, does not change. The russh version pin is a one-way door
in practice (SSH key exchange/kex algorithm negotiation crosses
crate boundaries); bumps are deliberate, tested, and recorded.
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
timeout, auth attempt limits). SSH servers are internet-facing;
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,
and never copy or derive code from incompatible-licensed reference
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
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
surface is `lib.rs` re-exports. The mapping table
(channel-type → family protocol) is the crate's spine; each mapping
lives behind its feature and speaks only its family protocol's
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
family protocol halves (alktty/alktunnels/alksocks/alkgit). It
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,
and brings identity/ACL configuration. No feature implies another;
the feature graph is independent and flat until architecture says
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
Run these before committing. All must pass.
@@ -164,6 +179,9 @@ cargo clippy --all-targets -- -D warnings
cargo fmt --check
cargo doc --no-deps # if docs changed
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
# feature build (none / git / session / tunnel /
# 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
`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
feature.
@@ -187,53 +205,53 @@ Any file under `tasks/` is a taskgraph task. Frontmatter is required
isolated|component|phase|project`, `level:
planning|decomposition|implementation|review|research`. A file without
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
semantics live in `docs/sdd_process.md` §"Task File Format" /
§"Categorical Estimates".
## 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,
Accepted) records the structural decision already made: russh-wrapped
adapter, no in-family SSH framing, fail-closed capability features,
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
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
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
phase 0 document is the current work item); `docs/architecture/` +
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
standalone mode (POC independent of repo code; scratch project at
`/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.
## Architecture Context
- `docs/sdd_process.md` — the SDD process (phases, roles, task format)
- `docs/research/` — phase 0 research (current source of truth for
- `docs/sdd_process.md`: the SDD process (phases, roles, task format)
- `docs/research/`: phase 0 research (current source of truth for
design direction):
- `vision.md` — vision, guiding principles, non-goals, the phase 0
- `vision.md`: vision, guiding principles, non-goals, the phase 0
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,
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
- `auth-bridge.md` — russh auth ↔ alkcall `IdentityProvider` mapping
- `auth-bridge.md`: russh auth ↔ alkcall `IdentityProvider` mapping
(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)
- `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
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
- family docs: `/workspace/@alkdev/alkfamily/docs/` —
- family docs: `/workspace/@alkdev/alkfamily/docs/`;
`architecture/decisions/003-alkssh-russh-adapter.md` is the structural
decision this repo builds from (and `002-alkforge-gitea-replacement.md`
explains why: alkforge's git door, fail-closed posture);
@@ -249,8 +267,8 @@ production code.
analog for the adapter shape.
- alknet `main` key model reference:
`/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
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).
+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
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
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
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
(session/pty → alktty, direct/forwarded-tcpip → alktunnels, git exec →
alkgit), each behind its cargo feature in a flat, independent graph
(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
never advertised. Identity is the family's: a verified key's
fingerprint → alkcall `IdentityProvider`; keys the crate holds come
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
features, and brings identity/ACL configuration.
### Added
**Phase 0/1 — research and architecture** (the contract the
**Phase 0/1, research and architecture** (the contract the
implementation lands):
- SDD process, phase 0 research set (`docs/research/`), and the three
passed POCs (auth bridge, vault keys, mapping bake-off; findings in
`poc-1..3` + `reverse-runner.md`; POC source never merged).
- 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
graph; auth dispositions; vault-key custody; the mapping/bridge/
dispatch decisions.
**Wave 1 — foundations:**
**Wave 1, foundations:**
- The manifest: the russh 0.65 pin (`aws-lc-rs`, default features
off), the flat feature graph (`session` / `git` / `tunnel` /
`sftp`, `default = []`).
- The fail-closed error taxonomy (`thiserror`): `AdapterError`,
`DialError`, `KeySourceError`, `OpError`, `OpenDeclineReason` —
`DialError`, `KeySourceError`, `OpError`, `OpenDeclineReason`:
declines are dispositions, never panics; no `unwrap` outside tests.
- Vault-backed key custody in code: the `KeySource` /
`HostKeySource` seams and the ADR-005 implementations
(`VaultKeySource`, `VaultHostKeySource`) — decrypt-at-use /
(`VaultKeySource`, `VaultHostKeySource`): decrypt-at-use /
decrypt-at-construction, the `Zeroizing` seam, one
`decode_secret_key` conversion.
- 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
fingerprint path, the `SHA256:` format both the auth bridge and the
host-key policy consume).
- The config surface: `ServeConfig`, `DialConfig`, `Limits` (the
bounded-resources budget model — wall clock always set, channel
caps, keepalive posture) — unset fields decline at request time
bounded-resources budget model: wall clock always set, channel
caps, keepalive posture); unset fields decline at request time
(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);
`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`,
fail-closed on every path; bounded auth attempts.
- Parse-only dispatch + the fail-closed decline core: every unroutable
@@ -78,12 +153,12 @@ implementation lands):
caps (refuse-never-queue) and listener caps.
- 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`,
`TunnelListenSeam`): unset seam → decline even with the feature on.
- 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
alkgit; a subsystem request can never carry the repository and
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),
control rows (window-change, signal) on a bounded per-channel
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
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
the token router (miss → teardown-at-birth), the data leg opens
`forwarded-tcpip`/`forwarded-streamlocal` back toward the client,
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
publickey attempt; `SshConnection` the handle.
- The op rows: `exec` / `pty_exec` / `shell` (the alktty chunk plane),
`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).
- 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
channels.
- 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
crate ships primitives, the assembly composes).
- Hardening riders: the git row's status-before-close by construction,
the channel-not-held guard wire rows, tty control-row coverage, the
`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
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`,
`-D` + curl), git clone and push through the door, and a real
`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,
fingerprint format, key decode, `TerminalParamsWire`) with the
committed-seed corpus gate (`cargo test --manifest-path
@@ -141,7 +216,7 @@ implementation lands):
### 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 alkfs mapping lands (OQ-13; the decline stays contract
territory).
Generated
+7 -7
View File
@@ -109,7 +109,7 @@ dependencies = [
[[package]]
name = "alkssh"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"alkcall",
"alkgit",
@@ -131,9 +131,9 @@ dependencies = [
[[package]]
name = "alktty"
version = "0.8.1"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2265167842b7d3ea09bc235f48450d3dcf22049a2ca71f8155de00acbf97c226"
checksum = "9cab9b0d3d6c1bdef2622799e3b95bb6063720153a124a57f4c60f95f3d42ea8"
dependencies = [
"alkcall",
"async-trait",
@@ -153,9 +153,9 @@ dependencies = [
[[package]]
name = "alktunnels"
version = "0.4.1"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee56da894f2d02120d3602df32c7f6a9db7f930a401df66d40b632cd9767af41"
checksum = "198d3aa766889cf5cc9b764319f9aa75e21234d623021893c26675f7c4276242"
dependencies = [
"alkcall",
"bytes",
@@ -169,9 +169,9 @@ dependencies = [
[[package]]
name = "alkvault"
version = "0.1.0"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52e032b074f7fea71ea15528994c5b3a6503c082c519045bb3b0519281291918"
checksum = "b2522ded10a506770779e72063422057bb63f1b75457c1742a3362072adfb1c4"
dependencies = [
"aes-gcm",
"base64",
+46 -34
View File
@@ -1,6 +1,6 @@
# Single-crate protocol/door crate (the alktty template). No workspace:
# the composition story is deferred — alkssh's substance is the mappings
# (family ADR-003). The `fuzz/` sidecar is its own workspace (excluded —
# the composition story is deferred: alkssh's substance is the mappings
# (family ADR-003). The `fuzz/` sidecar is its own workspace (excluded:
# it pins its own nightly toolchain and never rides the root's).
[workspace]
members = ["."]
@@ -8,18 +8,18 @@ exclude = ["fuzz"]
[package]
name = "alkssh"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
rust-version = "1.89"
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"
keywords = ["ssh", "russh", "protocol", "alkcall", "network"]
categories = ["network-programming", "asynchronous"]
# The package policy (the sibling crates' convention): the published crate
# ships the architecture docs (the authoritative contract) but not the
# process surface — reviews, research, plans, task files, the working
# agents' config, AGENTS.md — and not the `fuzz/` sidecar (its own
# process surface: reviews, research, plans, task files, the working
# agents' config, AGENTS.md; and not the `fuzz/` sidecar (its own
# 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/"]
@@ -27,12 +27,12 @@ exclude = [".opencode/", "AGENTS.md", "docs/reviews/", "docs/plans/", "docs/rese
name = "alkssh"
[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
# declines fail-closed). The wave-2 decline core is the first `cfg`
# 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
# 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
# the wave-3 config fields and for alktunnels `w3-tunnel-direct`, the
# route-1 bridge).
@@ -44,38 +44,50 @@ sftp = []
[dependencies]
# 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.
async-trait = "0.1"
# ADR-002: aws-lc-rs only; default features off — no flate2, no rsa
# (ed25519-only; the tree stays RC-free except the ssh-key pin riding the
# ADR-002: aws-lc-rs only; default features off, meaning no flate2 and
# 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 = { version = "0.65", default-features = false, features = ["aws-lc-rs"] }
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`).
# Pins verified against crates.io (2026-10-28): alktty 0.8.1, alkgit
# 0.1.0 — both MIT OR Apache-2.0, both rust_version 1.88 ≤ our 1.89,
# 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.1", default-features = false, optional = true }
# Pins verified against crates.io (2026-11-04): alktty 0.8.2 (0.1.0's
# 0.8.1 pin refreshed; 0.8.2 is the PTY exit-signal-fidelity fix,
# patch-level, API/wire unchanged; alkssh consumes negative exit
# codes verbatim so the derivation contract needed no change), alkgit
# 0.1.0: both MIT OR Apache-2.0, both rust_version ≤ our 1.89,
# 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 }
# tunnel-only: the route-1 bridge's consumer surface (`TunnelSession::
# open_direct`/`take_halves`, `SubstrateAddr`/`Substrate`). Pin
# re-verified against crates.io at `w3-tunnel-direct` (2026-10-29):
# 0.4.1 is `max_stable_version`/`newest_version`, MIT OR Apache-2.0,
# rust_version 1.88 ≤ our 1.89, alkcall ^0.10.1 pinned 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.4.1", default-features = false, optional = true }
# open_direct`/`take_halves`, `SubstrateAddr`/`Substrate`) + the
# forwarded serving surface's establisher shape
# (`register_tunnel_forwarded_openable_with_establisher` +
# `ForwardedGate`, the review-003 W2 adoption, ADR-008 Amendment 2's
# recommended shape). Pin re-verified against crates.io (2026-11-04):
# 0.5.0 is newest_version, additive API only over 0.4.1 (the wire
# delta is empty, the base registration is byte-for-byte), MIT OR
# Apache-2.0, rust_version 1.88 ≤ our 1.89, alkcall ^0.10.1 pinned
# 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`)
# and the exit-chunk / negotiation-error-frame JSON reads in `tty_map.rs`.
# Same pin alktty carries (crate resolution stays single-versioned).
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
# carry (crate resolution stays single-versioned).
bytes = { version = "1", optional = true }
@@ -87,24 +99,24 @@ zeroize = "1.9"
[dev-dependencies]
# 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).
gix-hash = { version = "0.28", features = ["sha1"] }
# 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).
tokio-stream = "0.1"
# 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
# dynamic-bind producer serializes params/reply fields).
serde_json = "1"
# 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
# optional alktty dep in test builds; the published lib never enables
# it and `src/` never names a local shape). Same 0.8.1 pin.
alktty = { version = "0.8.1", default-features = false, features = ["local"] }
# it and `src/` never names a local shape). Same 0.8.2 pin.
alktty = { version = "0.8.2", default-features = false, features = ["local"] }
[profile.release]
lto = "thin"
+19 -19
View File
@@ -2,19 +2,19 @@
SSH adapter for the alk family: a thin wrapper around
[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
ssh; family protocol halves (alktty, alktunnels, alkgit) receive their
own chunk planes; the door itself speaks no family protocol and owns
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,
and brings identity/ACL configuration and the family fabric handles.
It is two-directional: a server mount serves vanilla ssh into the
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
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 |
| `git` | the exec-only git rows (`git-{upload,receive}-pack`) | alkgit |
| `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*
requests, `sftp` until alkfs) declines fail-closed in every build:
never a half-capability, never a silent fallback, and never advertised
— an unfeatured channel type is declined, not advertised. A request
never a half-capability, never a silent fallback, and never advertised;
an unfeatured channel type is declined, not advertised. A request
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
@@ -42,7 +42,7 @@ The assembly injects the seams: an alkcall
the per-feature family handles. The examples below are the crate's
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
use std::sync::Arc;
@@ -77,7 +77,7 @@ let adapter = alkssh::SshAdapter::new(serve).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
use std::sync::Arc;
@@ -102,18 +102,18 @@ let conn = alkssh::dial(alkssh::DialConfig::new(
.await?;
// op rows (under their features): conn.exec, conn.tunnel_direct,
// conn.git_service, conn.reverse_listen — see the crate docs.
conn.close().await; // clean disconnect — drop is never the clean path
// conn.git_service, conn.reverse_listen; see the crate docs.
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
$ cargo build
$ 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` /
`--all-features`).
@@ -122,22 +122,22 @@ matrix: no features / `git` / `session` / `tunnel` / `sftp` /
- **Keys are sources, never static material** (alknet-main's `main`
key model, ADR-005): the dial/host key seams are
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.
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
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
the surface.
- **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**;
any permissive policy is the assembly's to write.
- **Injected identity**: every wire-exposed op is checked against
alkcall's `AccessControl::check(peer_identity)`; internal ops are
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
attempt limit): the crate never constructs an unbounded session.
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
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
001–006, and the open-questions tracker.
## 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
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;
`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
`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
resolution pass closed all thirteen open questions (OQ-08/11 decided,
OQ-10 converted to a derivation contract + upstream constraint,
@@ -24,7 +24,7 @@ pass came back clean. Implementation may begin.
| Document | Status | Scope |
|---|---|---|
| [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 |
| [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) |
@@ -33,24 +33,24 @@ pass came back clean. Implementation may begin.
| ADR | Title | Status |
|---|---|---|
| [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-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-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-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-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-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 |
## 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
are resolved**: the three mapping-detail decisions settled this pass
(OQ-08 shell convention → assembly-supplied serve-config field, no
crate default; OQ-10 → closed as the derivation contract on
`mapping.md`'s exit rows + the upstream PTY-path constraint; OQ-11 →
universal compile + runtime Unix-gated decline), and the previously
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
deferred entries closed as fully-decided dispositions (OQ-01/04/09/13,
whose decision surfaces were already fixed by ADR-001/ADR-004 and the
decline postures; what remained was forecasts about consumers, not
pending alkssh decisions). Upstream dependencies are listed as watch
items in the tracker, not OQs.
@@ -66,12 +66,12 @@ items in the tracker, not OQs.
## References
- `docs/sdd_process.md` — the SDD process
- `docs/research/` — phase 0 (vision, POC plans + findings, russh
- `docs/sdd_process.md` (the SDD process)
- `docs/research/`: phase 0 (vision, POC plans + findings, russh
inventory, auth bridge, mapping table, reverse-runner extraction)
- Family: `/workspace/@alkdev/alkfamily/docs/architecture/decisions/001..003`
(ADR-003 is the alkssh structural decision)
- Sibling: alkcall ADRs 002/003/004/006/009/010/034/042/051 (ALPN
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)
+34 -34
View File
@@ -3,7 +3,7 @@ status: reviewed
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
@@ -20,43 +20,43 @@ verifies a signature; its auth surface is *dispositional* (ADR-004).
### The bridge
1. `auth_publickey(user, VerifiedPublicKey)` fires (post-verification;
0.65.0 shape — the key is the `.public_key` field, and the client's
signature `.algorithm` is exposed for optional hardening).
the 0.65.0 shape has the key as the `.public_key` field, and the
client's signature `.algorithm` is exposed for optional hardening).
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
url-safe alphabet). The string format is a **contract invariant**
(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/`).
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.
4. On success the identity rides the session (`auth_succeeded`): for
mapped channels it propagates through the alkcall transport
(`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
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
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
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
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` /
`auth_password` / `auth_keyboard_interactive` / `auth_openssh_certificate`
reject explicitly (at this pin russh's defaults already reject; the
explicit implementations are the family contract — a pin bump could
change defaults silently). `Config::methods` advertises `{publickey}`
— exactly what is implemented. The username is **not identity and
explicit implementations are the family contract, since a pin bump could
change defaults silently). `Config::methods` advertises `{publickey}`,
exactly what is implemented. The username is **not identity and
carries no semantics** (OQ-03 resolved); log exposure is deployment
policy. `max_auth_attempts` defaults `10` (the OpenSSH `none`-probe
consumes one — POC-1 §4).
consumes one, POC-1 §4).
## 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,
zeroized ownership at the adapter seam**; never env vars, never
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
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
server's `Config::keys`; locked vault → **no server is constructed** —
server's `Config::keys`; locked vault → **no server is constructed**;
POC-2 §5).
### 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
their host key at assembly time): assembly-supplied pins —
**hard-decline on mismatch** at `check_server_key`.
their host key at assembly time): assembly-supplied pins, with a
**hard decline on mismatch** at `check_server_key`.
2. **Unknown beforehand** (vanilla `sshd` on rental fleets; no anchor
until contact): the crate surfaces
(host, port, presented fingerprint) to an **assembly-provided
`HostKeyPolicy`** — prompt-and-record, provider-metadata match,
learn-and-record, or accept-and-continue (the ephemeral-fleet
shape). The OpenSSH "add to known_hosts?" moment becomes
assembly policy.
`HostKeyPolicy`**, which chooses prompt-and-record,
provider-metadata match, learn-and-record, or accept-and-continue
(the ephemeral-fleet shape). The OpenSSH "add to known_hosts?"
moment becomes assembly policy.
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
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; 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).
## What is out of scope
@@ -104,21 +104,21 @@ the tunnels, not host pins).
- Password / keyboard-interactive anywhere (server: declined; client:
non-goal until a use case).
- 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
upstream identity-model work as its prerequisite).
- ssh-agent integration/forwarding (external signer processes are out
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).
## 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
username disposition — §Dispositions), OQ-04 (the certificate decline
— §out-of-scope), OQ-05 (the host-key policy seam — §Host-key policy),
OQ-06 / OQ-07 (custody boundaries — ADR-005).
username disposition, §Dispositions), OQ-04 (the certificate decline,
§out-of-scope), OQ-05 (the host-key policy seam, §Host-key policy),
OQ-06 / OQ-07 (custody boundaries, ADR-005).
## 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`
(§2–5), `reverse-runner.md` (lessons 5–6)
- 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 /
family ADR-003 decision 2
+57 -55
View File
@@ -3,14 +3,14 @@ status: reviewed
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 client direction is a door *to vanilla servers*: family in,
vanilla out. The motivating consumer is the **reverse-runner** shape
(`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
maps family-side onto the same protocols the server direction serves.
The asymmetry is honest (vision principle 2): the server direction
@@ -24,76 +24,77 @@ up into the family surface.
## The dial surface
Every dial needs: address, **key source** (ADR-005 — vault-backed
blob, decrypt-at-dial, fail-closed on locked/absent — never env vars,
never plaintext at rest), **host-key policy** (OQ-05 resolution —
Every dial needs: address, **key source** (ADR-005, a vault-backed
blob: decrypt-at-dial, fail-closed on locked/absent, never env vars,
never plaintext at rest), **host-key policy** (the OQ-05 resolution,
assembly-provided; crate default fail-closed reject-unknown, hard
decline on mismatch; the OpenSSH "add to known_hosts?" moment is an
assembly policy, e.g. learn-and-record / prompt / provider-metadata
match — the crate is policy-blind), and the bounded-resource config
(`keepalive_interval`/`keepalive_max` — 15 s / 3 default posture per
match, and the crate is policy-blind), and the bounded-resource config
(`keepalive_interval`/`keepalive_max`, a 15 s / 3 default posture per
the reverse-runner evidence; `inactivity_timeout`; window/packet
knobs per `overview.md`).
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
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.
## Op rows (family op → vanilla translation)
| 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` |
| 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` |
| 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` |
| 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` |
| 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
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 +
`reverse-runner.md` lesson 1). The client contract:
- Server-initiated opens are served only when the assembly registered
an acceptor **for that type and under its feature** (`forwarded-tcpip`
under `tunnel` is the real case — the `-R` data path).
an acceptor **for that type and under its feature** (the real case
is `forwarded-tcpip` under `tunnel`, the `-R` data path).
- Unregistered/unfeatured accepted opens are **declined cleanly**,
never silently dropped after accept.
- 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
assembly-mutable acceptor registry).
> **The landed acceptor shape (`w4-reverse-listen` — recorded at
> landing):** the registry is **per-type fixed slots** — exactly the
> **The landed acceptor shape (`w4-reverse-listen`, recorded at
> landing):** the registry is **per-type fixed slots**, exactly the
> two `-R` data-leg types the client direction can serve
> (`forwarded-tcpip`, `forwarded-streamlocal`, both under `tunnel`;
> no map, no generic key), one acceptor trait per type; install is
> **refuse-never-replace** (the `ListenRouter::register` recorded
> 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
> 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) +
> 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
> registry bounded by construction (two slots); the
> **reverse-listen count** and the **accepted-open handoff count**
> are two dedicated counters, each against the same
> `Limits::max_concurrent_channels` ceiling (the server-side
> listener/ListenerCap posture mirrored — independent counts, one
> ceiling; the listens live at the peer, the counter exists so a dial
> listener/ListenerCap posture mirrored: independent counts, one
> ceiling; the listens live at the peer, and the counter exists so a dial
> cannot mint an unbounded listener set). The **forward-request rows
> 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
> `OpError::ListenRefused` (the peer's `RequestDenied` — denied
> `OpError::ListenRefused` (the peer's `RequestDenied`:
> denied
> listens and unknown-listener cancels, sshd raw-string parity) and
> `OpError::ListenCapExceeded` (the ceiling, pre-wire). The
> **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
cancellable; disconnects are explicit (`Disconnect::ByApplication`).
- **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
primitives (bound ports, handles, cancellation, clean close) to
make that possible.
> **The landed lifecycle surface (`w4-lifecycle` — recorded at
> landing):** the `-R` listens return **`ReverseListener` handles** —
> the `ReverseEndpoint` carried (the *actually bound* values:
> the wire address + the bound port / the socket path — the
> assembly's own record for a rebind policy it may compose), the
> explicit `cancel()` the wire-honest teardown (the confirmed cancel
> **The landed lifecycle surface (`w4-lifecycle`, recorded at
> landing):** the `-R` listens return **`ReverseListener` handles**,
> with the `ReverseEndpoint` carried (the *actually bound* values:
> the wire address + the bound port / the socket path, the
> assembly's own record for a rebind policy it may compose) and the
> explicit `cancel()` as the wire-honest teardown (the confirmed cancel
> also releases the connection's listener count; a repeat rides the
> wire and hits the unknown-listener refusal). The **stateless**
> 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
> after the handle itself is gone: the handle shares 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
> 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
> 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
> disconnect message). The primitives-not-policy posture is 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)
- **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
assembly work composed over the exec rows; the crate gives the
channels and the exit semantics (including the pipe-mode
pid-only signal caveat — `mapping.md`'s derivation contract).
- **Credential storage policy** — assembly (the vault handle is
pid-only signal caveat from `mapping.md`'s derivation contract).
- **Credential storage policy**: assembly (the vault handle is
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
ephemeral fleets is the assembly's to choose — the crate carries no
trust model of its own).
- **sftp** — the `sftp` feature is the tested decline placeholder on
ephemeral fleets is the assembly's to choose, since the crate
carries no trust model of its own).
- **sftp**: the `sftp` feature is the tested decline placeholder on
both directions (OQ-13 resolved); the alkfs-protocol mapping
arrives with alkfs, as its own ADR.
## 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
> **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
> 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
> 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
> respected by construction. The session rows' family plane is the
> 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
> codes, the client arm mirrors wire reports into family codes,
> `−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
request failures surface as `ChannelMsg::Failure` via `wait()`, not
`Err` from the call — the caller must drain until the expected
message, `continue` past the rest.
`Err` from the call; the caller must drain until the expected
message and `continue` past the rest.
- `window_change`/`signal` are fire-and-forget and must be sent
**before** `into_stream()` (the Channel is consumed by conversion);
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.
- Keepalives on cloud fleets: 15 s / 3 (OpenSSH
`ServerAliveInterval=15`/`CountMax=3` parity) — with russh's
default (none), dead NAT'd connections sit unnoticed until an exec
`ServerAliveInterval=15`/`CountMax=3` parity), since with russh's
default (none) dead NAT'd connections sit unnoticed until an exec
health check fails (`reverse-runner.md` lesson 3).
- A rejected channel open is an `Err` from the typed open call (not a
`wait()`-match); the connection stays usable after declines.
## 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
host-key policy — §The dial surface), OQ-12 (lifecycle primitives only
— §Lifecycle primitives), OQ-13 (the sftp decline placeholder — §op
rows and §not-include), OQ-07 (the adapter-side `Zeroizing` wrap —
ADR-005 decision 3).
host-key policy, §The dial surface), OQ-12 (lifecycle primitives only,
§Lifecycle primitives), OQ-13 (the sftp decline placeholder, §op
rows and §not-include), OQ-07 (closed upstream: the vault returns a
`Zeroizing<String>` from alkvault 0.2.0; ADR-005 decision 3 amended,
the adapter-side wrap dropped).
## References
@@ -3,12 +3,12 @@ status: reviewed
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
**Accepted** — 2026-10-26. One-way door (ledger rule 1: ALPN registry
entries are stable — "once published, does not change"). The first
**Accepted** (2026-10-26). One-way door (ledger rule 1: ALPN registry
entries are stable; "once published, does not change"). The first
consumer is alkforge (family ADR-003 decision 4: the pin lands before
alkforge becomes the consumer).
@@ -21,11 +21,11 @@ pin means:
- **ADR-004** (ALPN convention + connection model) reserves
`alk/ssh` → `SshAdapter` as a custom protocol.
- **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
channel data.
- **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
rewrite, ACL fired at `channel/open` by the hub's `CallAdapter`).
- **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
scope question 1): whether `alk/ssh` carries raw vanilla-SSH bytes
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
**edge termination** (russh terminates vanilla SSH at the alkssh
endpoint; family seams are consumed in-process or via `alk/channels`
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`
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
connection opened under `alk/ssh` carries a **raw vanilla-SSH byte
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
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
ADR-042's relay ruling).
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,
restated as the ALPN's own contract so it cannot drift.
3. **Phase 1 serves edge termination only.** The first deployment
shape — russh terminating vanilla SSH at the alkssh edge, family
seams consumed in-process — does not speak `alk/ssh` itself: it is
shape (russh terminating vanilla SSH at the alkssh edge, family
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
relay leg to exist.
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).
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
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,
never accept-and-stall (the same honest-advertisement rule that
runs through every alkssh decision).
@@ -88,18 +88,18 @@ later.
(frozen here) from the *served edge topology* (which evolves with
the family seams); the browser and relay routes are pre-enabled at
zero pin cost; the ledger gets a single unambiguous entry.
- **Negative**: one-way door — the raw-vanilla-SSH-stream contract
binds every future consumer (alkforge first). If a consumer ever
- **Negative**: the pin is a one-way door; the raw-vanilla-SSH-stream
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
ALPN + new ADR, not a reinterpretation.
- **Neutral**: `SshAdapter::handle` mounts the full russh server
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.
## 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
(the `alk/ssh` → `SshAdapter` reservation), ADR-009 (browser-WASM
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/vision.md` scope Q1,
`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)
+20 -20
View File
@@ -3,16 +3,16 @@ status: reviewed
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
**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
recorded per the repo's wire-stability convention). Amended same day
after review: the earlier enabling of the `rsa` feature is reverted
(`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).
## Context
@@ -21,9 +21,9 @@ constraint, not fleet compatibility (decision 4).
2026-10-26 re-verification section carries the current facts. The POCs
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
fixes — three in classes an internet-facing SSH door cares about
(client EXT_INFO unbounded allocation, peer zero-max-packet-size
channel stall, pre-auth nested-certificate stack exhaustion) — and two
fixes, three of them in classes an internet-facing SSH door cares
about (client EXT_INFO unbounded allocation, peer zero-max-packet-size
channel stall, pre-auth nested-certificate stack exhaustion), and two
breaking changes confined to handler-signature shapes
(`PublicKeyOffer` / `VerifiedPublicKey`; `async-trait` feature
removed).
@@ -31,9 +31,9 @@ removed).
The family's crypto-posture context: `alktls`, `alkhttp`, and
`alknet` all standardize on `rustls` with the **`aws_lc_rs`** feature;
no family crate links `ring`. The provider choice matters only as
binary-composition hygiene — when alkforge's binary links alkssh
*and* the family's rustls stack, one shared primitive library is the
goal — and has nothing to do with TLS on the SSH wire.
binary-composition hygiene (when alkforge's binary links alkssh
*and* the family's rustls stack, the goal is one shared primitive
library) and has nothing to do with TLS on the SSH wire.
## 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
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
platform ever fails to provision aws-lc — a deliberate,
platform ever fails to provision aws-lc, a deliberate,
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 is largely pointless at modern link layers and is the
CRIME-adjacent attack surface OpenSSH itself defaults off.
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
review: the position is ed25519-only. One key algorithm carries
the family's identity: the same ed25519 keys serve alktls's
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
(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.
Enabling `rsa` would additionally have pulled two RC-pinned deps
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
the `russh::keys` path). Wire behavior is honest and fail-closed:
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
ed25519 fingerprints only; and `auth_publickey` **declines
non-ed25519 keys explicitly** (ADR-004's explicit-over-inherited
pattern applied to the algorithm position, so the posture survives
a pin bump that might otherwise accept more schemes). If a scheme
ever matters, the amendment is one feature flag plus this ADR.
5. **`des` / `dsa` never.** russh's own "Danger: insecure" features —
no alkssh configuration may enable them.
5. **`des` / `dsa` never.** russh's own "Danger: insecure" features,
which no alkssh configuration may enable.
6. **Manifest lands with the dependency**: `rust-version = "1.89"`
(russh's MSRV; supersedes the current 1.88) and, with it, the
edition-2024 bump the POCs compiled under.
@@ -91,12 +91,12 @@ goal — and has nothing to do with TLS on the SSH wire.
## Consequences
- **Positive**: the pin is current, carries the security fixes, and
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`**
matches the family crypto posture; the tree is fully inventoried,
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);
MSRV/licensing are explicit.
- **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
russh moves off it; aws-lc-rs is the heavier build (proven
acceptable here, with `ring` as the recorded fallback).
@@ -3,11 +3,11 @@ status: reviewed
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
**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
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
vanilla SSH multiplexes tunnel legs through `direct-tcpip`/`forwarded-tcpip`
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*
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.
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
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
is client-side; its SOCKS-in-channel serve path was subsequently
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 |
| `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 |
| `sftp` | *placeholder — declines until alkfs lands (OQ-13 resolved)* | client sftp half, same placeholder | alkfs (future) |
| `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) |
**1a. No `dynamic-forward` feature.** Amended 2026-10-26 (review
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
alksocks"). No consumer of ours is that client: vanilla `ssh -D`
does its SOCKS5 handshake client-side and opens plain
`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
`direct-tcpip` opens, composed at the assembly. The server needs
only the `tunnel` row for the whole `-D` leg, and alkssh does not
depend on alksocks on this path. The SSH-detect dispatch from
ADR-006 point 3 retires with it (session dispatch is parse-only;
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
back is an explicit ADR —
the two transports are "just byte streams", so nothing forbids
it; nothing needs it.
back is an explicit ADR. The two transports are "just byte
streams", so nothing forbids it; nothing needs it.
2. **Flat and independent.** No feature implies another. `default = []`
— a default build has no capabilities at all; every unfeatured
2. **Flat and independent.** No feature implies another. `default = []`:
a default build has no capabilities at all; every unfeatured
channel type/open/request declines with a clean error. `git` does
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
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.
3. **Two-direction symmetry.** Each feature name gates *both* the
server-direction (what an embedded alkssh serves vanilla clients)
and the client-direction (what a family app drives when dialing an
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
any more than it can serve one.
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
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).
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
model; x11 has no family seam). An ADR amendment is required to
ever feature-gate them.
@@ -104,21 +103,21 @@ feature set except sftp (placeholder until alkfs lands).
surfaces can't drift apart on capability naming.
- **Negative**: the split of `session` makes feature interplay a
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).
- **Neutral**: feature *names* are now contractual (downstream builds
specify them); renames would be semver-visible — hence the ADR, not
incidental naming.
specify them); renames would be semver-visible, hence the ADR rather
than incidental naming.
## References
- `mapping-table.md` (the validated rows under these names + the
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)
- `docs/research/reverse-runner.md` (client-direction surface the
symmetry rule serves; the acceptor default fact)
- `docs/research/poc-3-mapping-bakeoff.md` (stages A–D: the seams at
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)
@@ -3,16 +3,16 @@ status: reviewed
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
**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
ADR. Amended 2026-10-27 (review pass, per ADR-002 decision 4's
ed25519-only amendment): `auth_publickey` additionally declines
non-ed25519 keys explicitly — the key-algorithm posture, not the
fingerprint disposition, changes; all other dispositions stand. Not a
non-ed25519 keys explicitly (the key-algorithm posture, not the
fingerprint disposition, changes); all other dispositions stand. Not a
ledger item (no wire format changes), but the decline
matrix is contract territory: every decline is tested on both sides of
every feature.
@@ -22,7 +22,7 @@ every feature.
The family identity model has exactly one SSH-shaped resolution path:
a verified key's fingerprint → alkcall `IdentityProvider::
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
(POC-1 observed), and invalid/malformed signatures produce
`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
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`,
`SHA256:` + unpadded base64 over the standard alphabet —
`SHA256:` + unpadded base64 over the standard alphabet,
`+/`, not the url-safe alphabet; byte-identical to
`ssh-keygen -lf`) →
`resolve_from_fingerprint` → `Some` → accept with the resolved
identity; `None` → reject. The fingerprint *string format* is a
contract invariant: registration strings from any standard issuer
(ssh-keygen, alkcall-side computation) must compare equal —
POC-1 proved parity against `ssh-keygen -lf` output.
(ssh-keygen, alkcall-side computation) must compare equal; POC-1
proved parity against `ssh-keygen -lf` output.
2. **`auth_publickey_offered` is accepted-and-delegated.** The probe
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).
3. **Every other method is explicitly declined, including when inert
at the pin.** `auth_none`, `auth_password`,
`auth_keyboard_interactive`, `auth_openssh_certificate` each get
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 pin is a one-way door and a future bump could silently change
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
bump that changes defaults.
- **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
non-goals (`vision.md`) until a consuming use case arrives; the
server never gains them without amending this ADR (a wire-visible
@@ -3,11 +3,11 @@ status: reviewed
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
**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
via a new ADR.
@@ -16,7 +16,7 @@ via a new ADR.
On the client direction alkssh *holds* secret material (dial keys); on
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
posture).
- 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`
§The gap). Held SSH keys are bearer-token-class secrets: opaque
bytes, vault-encrypted at rest, decrypted at use.
- The vault is **never** involved in resolving *clients'* fingerprints
— that path is the assembly-layer `IdentityProvider` (scope note in
- The vault is **never** involved in resolving *clients'* fingerprints;
that path is the assembly-layer `IdentityProvider` (scope note in
`pocs.md` §POC-2).
alkvault 0.1.0's `encrypt`/`decrypt` surface is `&str` in, `String`
out (`EncryptedData` as the wrapper) — a decisive constraint on any
alkvault's `encrypt`/`decrypt` surface is `&str` in, `Zeroizing<String>`
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
both ends).
@@ -42,9 +43,9 @@ both ends).
boundary (`ssh -i` accepted the decrypted bytes directly), and
exactly one decode call (`decode_secret_key(plain, None)`) from
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
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
bytes of a PEM file" survives every algorithm ssh-key can decode),
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
streaming while new dials fail (POC-2 verified); unlock is
side-effect-free on key identity.
3. **Zeroization ownership (the known gap).** ssh-key zeroizes
ed25519 key bytes on `Drop` (decoded keys, and their clones, clean
themselves). The *decrypted plaintext* is the gap: alkvault's
`decrypt` returns a plain `String`, so from the vault's return to
the adapter's wrap the plaintext sits in unfenced heap. The
adapter owns the wrap (proven in POC-2) — **that is the contract on
this path**; the vault-side improvement (a `Zeroizing<String>`-out
API) is an upstream ask filed in alkvault's own tracker, not an
alkssh obligation — when it lands, this ADR's pointer updates as a
one-line doc touch (alkssh OQ-07 is closed at the
adapter-convention level).
3. **Zeroization ownership (the gap, closed upstream).** ssh-key
zeroizes ed25519 key bytes on `Drop` (decoded keys, and their
clones, clean themselves). The *decrypted plaintext* was the gap:
alkvault's `decrypt` once returned a plain `String`, leaving the
plaintext in unfenced heap between the vault's return and the
adapter's wrap; the adapter owned the wrap then (proven in POC-2;
**that was the contract on this path**) and the vault-side
improvement (a `Zeroizing<String>`-out API) was an upstream ask
filed in alkvault's own tracker. The ask shipped in **alkvault
0.2.0** (2026-11-04, the tracker's review 001; this ADR is the
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.**
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
failure, no dial. Unknown `key_version` → vault's `InvalidPath`
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
adapter's to reshape, never to collapse.
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.
6. **Host keys ride the same model.** Server host keys are
decrypt-at-construction into `Config::keys` through the same
load path; the client's host-*pin* is public config, never vault
material (OQ-05's two-case policy handles pinning). The two-case
host-key policy *seam* is the crate's trust-decision contract —
the crate is policy-blind (fail-closed default; a permissive
case-2 policy is an assembly choice) — the contract lives in
host-key policy *seam* is the crate's trust-decision contract: the
crate is policy-blind (fail-closed default; a permissive
case-2 policy is an assembly choice), and the contract lives in
`auth.md`/`client.md`.
7. **Plaintext never persists.** No disk spill on this path, ever;
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).
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
comment claiming an SSH-agent implementation is stale); a
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
bytes exist client-side at *either* dial time or sign time;
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
at-rest bytes are plain OpenSSH key files, so alkvault stores and
rotates them with no SSH-specific logic.
- **Negative**: decrypted plaintext has one unfenced window (before
the adapter's wrap) until alkvault changes its API — the gap is
recorded, owned, and filed with its owner (the alkvault tracker;
OQ-07 resolved at the adapter level), not silently absorbed.
- **Negative**: ~~decrypted plaintext has one unfenced window (before
the adapter's wrap) until alkvault changes its API~~; the gap is
resolved (alkvault 0.2.0's `Zeroizing<String>` return; the adapter
wrap dropped, OQ-07 amended). No plaintext window remains on this
ADR's path.
- **Neutral**: the seed-variant size optimization remains noted as a
legitimate future option if blob size ever matters; adopting it
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
Signer dismissal, §5 host keys end to end)
- 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
- OQ-05, OQ-06, OQ-07, OQ-13 (all resolved)
@@ -3,14 +3,14 @@ status: reviewed
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
**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
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
simply declines. The parse-vs-anything split was only ever needed if
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
succeeds → `copy bidirectional`). POC-3 stage C proved it end to
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
(route 2: alkssh-shaped `ChannelOperations` in a serving
endpoint's registry) is **rejected**: it inverts the door posture
by putting adapter work upstream of the door. The alktunnels
*absence* note (no serve-over-stream entry) is thereby resolved —
route 1 never needed one.
*absence* note (no serve-over-stream entry) is thereby resolved,
as route 1 never needed one.
2. **Git over SSH is exec-only; every subsystem request declines.**
The vanilla git connection model is: git spawns its ssh transport,
which opens one session channel and sends
`exec_request "git-{upload,receive}-pack '/repo'"` — the repo path
rides inside the command string, so a subsystem request (name
`exec_request "git-{upload,receive}-pack '/repo'"`, so the repo path
rides inside the command string, and a subsystem request (name
only, no repo id) can never dispatch. POC-3 proved the parse
grammar (quoted/unquoted paths, service-only → decline,
non-git → parse-then-fail with no resource alloc) and
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
`ssh -s`-style invocation, with a reason.
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
parse path (git under `git`, pipe/shell under `session`);
request-less session channels ran a SOCKS-detect
(`dynamic-forward`: spawn, first-byte `0x05` sniff, 500 ms
bounded window). With `dynamic-forward` removed from the feature
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
still resolves OQ-02 (the composition was the unknown; the
resolution is now the simpler of the two candidates).
4. **The spawn-and-return rule is a structural constraint, not an
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
proved by construction: receive-pack pushes bytes immediately
after the exec request). Every serving path is
`tokio::spawn` + handler-returns-fast; exit reporting from the
spawned task rides the cloned `Session::handle()` —
`exit-status`(then `close`) before channel close, `exit-signal`
spawned task rides the cloned `Session::handle()`:
`exit-status` (then `close`) before channel close, `exit-signal`
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
@@ -89,11 +89,11 @@ alkgit/alksocks/alktunnels/alktty).
alkforge assembly gets an exact wiring recipe (POC-3 §C.4's
in-process fabric leg is the template, verbatim).
- **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
preserved in the reference here).
- **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).
## References
@@ -103,8 +103,8 @@ alkgit/alksocks/alktunnels/alktty).
- `mapping-table.md` (the rows + the absence note this ADR resolves)
- ADR-003 (the feature gates these dispatch decisions sit behind)
- `docs/research/reverse-runner.md` (the client-direction mirror of
leg 1 — the `-L`/`-R` legs the same route-1 bridge serves)
- OQ-02 (resolved by this ADR), OQ-10 (resolved — the derivation
contract lives on `mapping.md`'s exit rows; the PTY-path
signal-identity limitation is recorded upstream, alktty/portable-pty
side)
leg 1, the `-L`/`-R` legs the same route-1 bridge serves)
- OQ-02 (resolved by this ADR), OQ-10 (resolved: the derivation
contract lives on `mapping.md`'s exit rows, and the PTY-path
signal-identity limitation is recorded upstream on the
alktty/portable-pty side)
+72 -67
View File
@@ -3,7 +3,7 @@ status: reviewed
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
@@ -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
seam** and **exactly one cargo feature** (ADR-003). Rows below are
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
@@ -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)` |
| 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).
## Channel-type rows
| 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` → `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` → `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`), 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` → `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 |
| `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) |
| `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) |
| `x11` | X forwarding | — (never) | — | 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) |
| `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` → `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) |
| `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 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 | none (never) | none | always declines; not feature-gated (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) | 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)
@@ -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
`pty_request`/`shell_request` route to `session`. Request-less session
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
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).
## Global-request rows (the `-R` listen flow)
`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
channels). The served flow (`w3-tunnel-listen`, decidable against
alktunnels 0.4.1's actual surfaces with POC-3 §C.4 as the template):
channels). The served flow (`w3-tunnel-listen`; the route-1 classes
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
wire bind endpoint>, substrate: Tcp/Unix})`. The resource is built
from the wire bind endpoint: the address string rides verbatim (the
OpenSSH client normalizes
the no-address case to `"localhost"` before it sends
(`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);
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
at the family's provider. The dial is bounded (`LISTEN_DIAL_TIMEOUT`
= 10 s, matching alkcall's own establisher deadline) and awaited
**inline** in the hook by wire necessity (the global request's
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
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.
- **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
fabric channel reaped immediately (a listen the door cannot
correlate arrivals onto never answers success). The bound port
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).
- **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
`tunnel/forwarded` op **toward the door** per arrival (the
`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
course-correction: the assembly's `Arc<ChannelClient>` alone
carries no inbound-op serving loop; the seam fronts the
`ServingConfig.registry` the assembly wired at the leg's
`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
per-identity cap + the `tunnel:forwarded` scope per arrival, then
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
`forwarded-streamlocal@openssh.com` with the path echoed (russh's
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
listener map (packet-loop-only, like the `channels` store) matches
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
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
pair. The listen count is a bounded resource: a dedicated counter
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
from the shared router (a fabric arrival after the session is gone
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 |
|---|---|
| `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 |
| `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 |
| `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 |
| `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 |
| `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 |
| `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 |
| `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) |
| `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_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 → `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)
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
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
`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)** →
`exit-signal(name)` via a Unix signal-number→name table (server-side
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
chunk, and SSH's `exit-signal` bool stays false. (POC-3 §D.2 observed
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
encoding on the Unix pipe path, and this rule accepts the fidelity
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")`.
If the wait-failure vs SIGHUP distinction ever becomes load-bearing,
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.
4. **Non-Unix**: no signal termination exists; `−1` is the only
negative that can appear → rule 3.
5. **The PTY path (fidelity limit, upstream)**: portable-pty 0.9 reaps
a signal-terminated Unix child as a plain code 1 (the signal number
is unrecoverable through its trait surface — verified against
portable-pty 0.9.0's source) → PTY-row signal-terminated sessions
arrive as `exit-status(1)`. Faithful `exit-signal` there is an
alktty/portable-pty ask with tty/pty-grounded justification; alkssh's
contract adapts automatically if the convention ever holds on that
path. Pipe-mode pid-only vs PTY process-group *scope* (the `signal`
5. **The PTY path (constraint retired, alktty 0.8.2)**: portable-pty
0.9's `ExitStatus` flattens a signal-terminated Unix child to a
plain code 1, so PTY-row signal deaths once arrived as
`exit-status(1)`. alktty 0.8.2 retires the constraint (its Unix
PTY waiter skips portable-pty's conversion and reaps the direct
child via `waitpid`, decoding signal deaths as `−N`); the PTY path
now reports signal termination exactly like the pipe path, and the
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.
6. **Unmapped negatives → rule 3.** The convention's producers are
`−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)`.
## The structural constraint: spawn-and-return
russh (this pin: ADR-002) runs server handler callbacks **inline
inside the session's packet loop** — a blocking handler suspends all
packet routing for the connection (channel data is routed by that
same loop; an inline serve deadlocks against the peer's immediate
follow-up bytes — POC-3 proved by construction with receive-pack).
Therefore: **every serving path is spawned; handlers return fast**;
inside the session's packet loop**, which means a blocking handler
suspends all packet routing for the connection (channel data is routed
by that same loop; an inline serve deadlocks against the peer's
immediate follow-up bytes, as POC-3 proved by construction with
receive-pack). Therefore: **every serving path is spawned; handlers return fast**;
exit/status reporting from spawned tasks rides the cloned
`Session::handle()`. This is a load-bearing correctness constraint of
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`,
`channel_buffer_size`, `Limits`, `inactivity_timeout`,
`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`.
- alkcall bridge leg (when the family half is remote): channels-layer
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 |
|---|---|---|
| none (default) | — | everything |
| none (default) | nothing | everything |
| `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 |
| `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 |
| `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 |
| 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
under `--all-features`.
## 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
dispatch, ADR-006 as amended — §above), OQ-08 (the shell argv / exec
wrapper serve config — `shell_request`/`exec_request` rows and the
dispatch, ADR-006 as amended, §above), OQ-08 (the shell argv / exec
wrapper serve config, the `shell_request`/`exec_request` rows and the
`session→pty+shell` channel row), OQ-09 (the `env_request` row),
OQ-10 (§the derivation contract — formalized, upstream constraint
noted there), OQ-11 (`direct-streamlocal` row + the listen-flow gate),
OQ-13 (the `sftp` rows — the decline placeholder is the shipped
OQ-10 (§the derivation contract, now formalized; the former upstream PTY
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
behavior).
## 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
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
- **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.)
- **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.)
- **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
- **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
### 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
- **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
- **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.
- **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, 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
## 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
- **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:
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.
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.
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)
### 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
- **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.
### 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
- **Status**: resolved — the alkssh-level answer is the adapter-side wrap; the vault-side improvement belongs upstream, not here.
- **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.
- **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 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
## 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
- **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:
- **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 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`.
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.
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.
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)
### 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)
- **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.
- **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, 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)
### 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.
- **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.
- **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.
- **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 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
### 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)
- **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.
- **Status**: resolved: universal compile, runtime decline.
- **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)
### 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
- **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`
## 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
- **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.
- **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.
- **Cross-references**: OQ-01, ADR-003, [client.md](client.md) §op rows, family ADR-002
## 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
their owners are:
- **PTY-path exit fidelity** — needs alktty/portable-pty work (with
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
- **alkfs landing**: the `sftp` placeholder's eventual replacement
(family ADR-002); alkssh writes the sftp mapping + ADR when the
protocol exists (OQ-13).
- **alkvault `Zeroizing`-out `decrypt`** — closes the pre-wrap
plaintext window upstream (OQ-07 / ADR-005 §3); a one-line doc touch
for alkssh when it ships.
- ~~**PTY-path exit fidelity**~~: closed upstream (alktty 0.8.2,
review 004's resolution): the PTY waiter now reports signal deaths
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
---
# Overview — alkssh
# Overview: alkssh
## 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
`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
would to any sshd; each accepted SSH channel/request is mapped onto
exactly one family protocol seam and exactly one cargo feature.
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,
runpod/vast.ai-style boxes) and the ops map family-side onto
alktty/alktunnels/alkgit types; the runner stays vanilla by design.
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.
## 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`) |
| 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) |
| 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) |
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
client surfaces, and carries no deployment policy. A downstream app
embeds it, enables features (ADR-003), brings identity/ACL config.
## 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:
- **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,
host-key policy, keepalive/timeout config), the op-row consumers
(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).
- **Key sources** (`auth.md`): vault-backed source construction per
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.
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
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) |
| 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.
## Non-goals
Per `vision.md`: no in-family SSH protocol machinery (ever); not an
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
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
@@ -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,
alkcall types, per-feature sibling deps: alktty/alktunnels/alkgit) and
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
- [README.md](README.md) (index), [open-questions.md](open-questions.md) (OQ-01…13)
- 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
- Family ADR-003 (the structural decision), family ADR-002 (alkforge;
why exclusion matters)
+120 -120
View File
@@ -1,11 +1,11 @@
---
status: closed
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)
---
# alkssh — Implementation plan
# alkssh: Implementation plan
Wave-based decomposition of the architecture (`docs/architecture/`)
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 mapping composes into), and decomposing only the next wave at a
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,
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**
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
rows, key sources, the decline matrix contract-tested per build). The
sftp decline placeholder *is* shipped behavior (OQ-13 resolved). **The
assembly stays out of these waves**: alkforge's mounting of
`SshAdapter::handle` at an alkcall `channel/open` boundary, its
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
root pattern; no tracks — one crate, one publish).
root pattern; no tracks: one crate, one publish).
## The waves
@@ -44,69 +44,69 @@ mappings ∥ client direction) → validation & release.
| Wave | Contents | Depends on | Status |
|---|---|---|---|
| [1](#wave-1--foundations) | Manifest landing (russh pin, feature graph); error model; config surface (serve-config + dial-config + `Limits`); vault-backed key sources; host-key policy seam; fingerprint; test doubles | — | decomposed |
| [2](#wave-2--the-server-stack) | `SshAdapter::handle` mount + `Config` construction; the auth bridge; parse-only dispatch + fail-closed decline core; spawn-and-return plumbing; bounded resources; loopback harness | wave 1 | 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 |
| [5](#wave-5--validation--release) | Decline-matrix contract suite; real-tool e2e; fuzzing; docs; **the publish** | waves 3 + 4 | published |
| [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 |
| [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 |
| [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
implement *against*, and nothing that needs a live SSH session to
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`,
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`
(the custody model), plus the standing toolkit (`tokio`, `thiserror`,
`tracing`, `parking_lot`, `zeroize`). `edition = "2024"`,
`rust-version = "1.89"`. The ADR-003 feature graph is declared from
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`,
`alktunnels` under `tunnel`, `alkgit` under `git`) land with their
first consuming wave, not as dead manifest entries. All family deps
pinned by version, never sibling-checkout paths; pins verified
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
(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,
never collapsed). Auth declines are *dispositions* (`Auth::Reject`),
not errors; wire decline reasons are wave-2 shaped. What the config
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
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
`IdentityProvider` handle; the host-key source handle; the tunnel
`ChannelClient` handle; the `Limits`), the **dial-config** (address,
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,
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
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
adapter seam → one-time `decode_secret_key(plain, None)`), the
client dial-key source and the server host-key load path
(decrypt-at-construction; locked vault → no server constructed),
`EncryptedData` opaque to the crate, plaintext never persists, every
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
pins (hard-decline on mismatch) and case-2 assembly policies are the
implementor's; the crate ships the fail-closed default (reject
unknown) and is otherwise policy-blind — no accept-all built-in.
- **Fingerprint** (ADR-004) — the `SHA256:` + unpadded base64
unknown) and is otherwise policy-blind; no accept-all built-in.
- **Fingerprint** (ADR-004): the `SHA256:` + unpadded base64
computation via the `ssh-key` re-export (`HashAlg::Sha256`); the
string format is a contract invariant, proven against independent
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
fingerprints → identities), an in-memory vault (encrypt/decrypt
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.
**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
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-fingerprint` (the contract-invariant computation + parity
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):
- **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
yet; the wave-1 manifest lands russh + alkcall + alkvault + the
toolkit only. The feature *names* are declared from the first
@@ -152,25 +152,25 @@ Interpretations recorded (flagged for the wave-1 gate):
shaped.
- **The policy verdict type** (bool vs a richer reason-carrying
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
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
frame, before any door is hung:
The russh server substrate every mapping composes into (the door
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
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:
honest advertisement (`methods = {publickey}` exactly, ADR-004
decision 4), host keys from the vault-backed source (locked → no
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
with the resolved identity / reject fail-closed; ed25519-only
(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
inherited defaults); the username carries no semantics (OQ-03).
- **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
channels decline), unfeatured channel types decline at open, x11 /
`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
matrix: unknown repo → collapsed generic denial before any protocol
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
return fast, exit/status reporting from spawned tasks rides the
cloned `Session::handle()`. The reporting *mechanics* land here; the
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`
wired from `Limits`, per-length exec-parse limits reserved for the
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
(in-memory doubles from wave 1; real-tool smokes ride the mapping
waves).
@@ -208,61 +208,62 @@ corrects.
green): wave 2 decomposed into six task files under
`tasks/implementation/` plus the gate. Shape: `w2-server-mount` first
(the `SshAdapter` mount + russh `Config` construction from the
serve-config — the substrate, with the Handler skeletoned at its
serve-config, the substrate, with the Handler skeletoned at its
fail-closed defaults); then `w2-auth-bridge` (the ADR-004
dispositions fill the auth hooks, over the mount + the wave-1
fingerprint path and doubles); then `w2-loopback-harness` (the
in-process client/server pair — the contract-test vehicle — proving
in-process client/server pair, the contract-test vehicle, proving
the doubles live through the stack); then `w2-dispatch-decline-core`
(parse-only dispatch + the fail-closed decline core, contract-tested
per feature build through the harness); then `w2-spawn-return` (the
structural constraint pinned by test + the exit-reporting mechanics
via the cloned `Session::handle()`); then `w2-bounded-resources` (the
in-crate channel cap at the handler surface); and `review-wave-2`
(the gate) depending on all six. The chain is deliberately sequential
— the auth hooks, the dispatch, and the cap all edit the same
(the gate) depending on all six. The chain is deliberately sequential:
the auth hooks, the dispatch, and the cap all edit the same
per-connection Handler impl, so no two tasks hold it in parallel.
Interpretations recorded (flagged for the wave-2 gate):
- **The git-parseability probe in wave 2 is skeleton-level** (service
prefix + path shape) — enough to contract-test the routing decision
prefix + path shape), which is enough to contract-test the routing
decision
and the session-build's git-gated decline; the full grammar
(quoted/unquoted paths, service-only → decline-with-reason,
per-length limits) is wave-3's git task, which replaces the probe.
- **Wave-2 routing stubs decline cleanly at the family-seam boundary**
— the seams land in wave 3; the stub behavior is documented as
- **Wave-2 routing stubs decline cleanly at the family-seam boundary**:
the seams land in wave 3; the stub behavior is documented as
skeleton so the routing decisions are contract-tested now. The
spawn-and-return mechanics are proven on a stub serving path.
- **The adapter error surface is shaped in `w2-server-mount`** — the
- **The adapter error surface is shaped in `w2-server-mount`**: the
construction error wrapping `KeySourceError` (locked → no server)
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 harness transport and placement are pinned in
`w2-loopback-harness`** (duplex pair vs TCP loopback; module
placement beside the wave-1 doubles) — flagged for the gate.
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),
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:
- **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 →
the reserved modes JSON); shell argv / exec wrapper from the
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`
rows; `env_request` declined unconditionally (OQ-09); the **exit
derivation contract** (`mapping.md` rules 1–6: `code ≥ 0` →
`exit-status(N)`, negative `−N` → `exit-signal(name)` via the
server-side Unix signal-number→name table, `−1` → `exit-status(255)`
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.
- **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,
service-only → decline-with-reason, non-git → parse-then-fail,
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,
…)` over the channel duplex, process-mode dialect, exit-status both
directions.
- **tunnel → alktunnels route 1** (ADR-006 decision 1) — the
adapter-side bridge: the serve-config's `ChannelClient` into the
family fabric (identity propagated once via `set_identity` so
`AccessControl::check` gates the mapped opens — the visible-surface
- **tunnel → alktunnels route 1** (ADR-006 decision 1): the
adapter-side bridge taking the serve-config's `ChannelClient` into
the family fabric (identity propagated once via `set_identity` so
`AccessControl::check` gates the mapped opens, the visible-surface
invariant), `TunnelSession::open_direct` + `take_halves()` pumped
against the russh channel, accept only after the fabric open
succeeds (failure → explicit `ConnectFailed` reject),
@@ -281,7 +282,7 @@ wave 5's:
clean open-decline off-Unix), the `forwarded-tcpip` handler shape,
the `-R` listen flow (`tcpip_forward`/`streamlocal_forward` + the
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
wave 4's acceptor/op-row work; the wave-5 matrix covers both
explicitly).
@@ -293,74 +294,73 @@ corrects.
green): wave 3 decomposed into six task files under
`tasks/implementation/` plus the gate. Shape: `w3-seam-config` first
(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
`w3-decline-tracing` (the wave-2 gate's booked finding: the decline/
serve-path event vocabulary, landed **before** the mappings so they
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
store-and-take handoff it lands first for the session task to reuse),
`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),
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
with wave 2, the branch DAG is sequential: the three mappings replace
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
*logistically*.
Interpretations recorded (flagged for the wave-3 gate):
- **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
map + selector + ownership provider; the alkgit backend quartet +
pipeline semaphore + push-options flag) were never carried by the
wave-1 config bullet, and `w3-seam-config` lands them feature-gated
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
consuming code (`w3-tunnel-direct`) to avoid a dead entry. The
unset-declines posture extends to the new handles (unset → the
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
name declines in every build) and are already contract-tested per
build in `tests/dispatch_core.rs`; no wave-3 task exists for it —
the client-side placeholder folds into wave 4's acceptor/op-row
build in `tests/dispatch_core.rs`; no wave-3 task exists for it: the
client-side placeholder folds into wave 4's acceptor/op-row
work and the wave-5 matrix covers both rows explicitly.
- **The `-R` listen flow carries the wave-3 gate's biggest open** —
the fabric-side inbound-forwarded mechanics (who dials the
- **The `-R` listen flow carries the wave-3 gate's biggest open**: the fabric-side inbound-forwarded mechanics (who dials the
forwarded op at the door, whether the assembly-supplied
`Arc<ChannelClient>` composition can carry the door-side accept or
the serve-config's tunnel handle must grow) is decided in
`w3-tunnel-listen` against alkcall 0.10.1 / alktunnels 0.4.1's
published surfaces; if the topology does not close, the task stops
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
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
decline-path tracing task (`w3-decline-tracing`), the forwarded-
tcpip acceptor vocabulary (`w3-tunnel-listen`, with the client-side
accept hook landing in the harness), and the deferred identity-
landing assertion (each mapping asserts the resolved identity
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:
- **Dial surface** (`client.md`) — connect with address, key source
(decrypt-at-dial, fail-closed on locked/absent — never env vars),
- **Dial surface** (`client.md`): connect with address, key source
(decrypt-at-dial, fail-closed on locked/absent; never env vars),
host-key policy at `check_server_key` (the wave-1 seam consumed:
case-1 pins hard-decline on mismatch, case-2 policies decide), the
bounded config (keepalives 15 s / 3, inactivity, window knobs). The
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:
`Channel::exec()` not `request_exec`, Success/Failure via
`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
server parses, pointed upstream); the sftp subsystem placeholder
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`
opens routed to the **acceptor registry**: served only under a
registered feature's acceptor, unregistered/unfeatured accepted
opens declined cleanly (never russh's accept-and-drop default),
late-install across a live connection (the alkminer dynamic),
`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
reconnect or restore policy owned by the crate** (assembly policy;
the primitives make it possible).
@@ -390,24 +390,24 @@ the mappings surfaced).
green): wave 4 decomposed into eight task files under
`tasks/implementation/` plus the gate. The wave-3 gate's four booked
findings are folded in as dedicated tasks (the gate's own
disposition — "wave-4 material"), not wave-3 backports: the
hardening set is server-side and independent of the client chain —
disposition, "wave-4 material"), not wave-3 backports. The
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
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
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
rows + the write_all no-deadline known-limit record), and
`w4-router-collision` (item 4: the `ListenRouter` token-collision
fail-closed pre-check); the gate's item 5 (the upstream asks) stays
booked with its owners — no task, nothing absorbed. The client chain
is deliberately sequential — `w4-dial` first (the russh client stack
booked with its owners: no task, nothing absorbed. The client chain
is deliberately sequential: `w4-dial` first (the russh client stack
+ the connection handle + the client Handler skeleton at fail-closed
defaults, over an in-process fake-`sshd` server double this task
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
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`
vocabulary into the crate's real surface), then `w4-lifecycle` (the
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):
- **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
rule) and the listen task grows the registry that serves them; the
split mirrors wave 2's mount-before-mappings shape and keeps the
dial task's surface reviewable.
- **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
vehicle here; real-`sshd` client-direction e2e stays wave-5
territory (the plan's wave-5 bullet), with per-task check-gated
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;
`w4-dial` decides + records if that proves wrong (the
`w3-seam-config` pattern), and the acceptor registry is
client-connection state, not serve-config.
- **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
the rows task (both touch `tests/tty_mapping.rs`), the wave-3
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`) ×
every row it can appear in × both directions, including the sftp
placeholder rows and the never-features (x11, `auth-agent`). The
fail-closed invariant is contract territory on both sides of every
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`
leg, decline observability), git 2.43+ (clone/push through the git
mapping), curl through a `-D` proxy; client direction against a real
`sshd` (the reverse-runner shape: exec, direct-tcpip dial, `-R`
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
fingerprint format path, the key-decode path, the TerminalParamsWire
conversion. The `fuzz/` sidecar convention (detached runner, corpus
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
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.
**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
upstream asks verified still booked with their owners, no task): wave
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
table cell by cell + the gap rows in the existing per-build binaries
+ the matrix runner joining the verification list), `w5-e2e-server`
(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
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/`
sidecar per the alkgit convention: four targets — exec_parse,
fingerprint, key_decode, terminal_params — the shared-library corpus
sidecar per the alkgit convention: four targets (exec_parse,
fingerprint, key_decode, terminal_params), the shared-library corpus
gate joining the verification list, the detached runner, the root
workspace `exclude`); then `w5-release-readiness` (rustdoc
warning-free × builds, README lands, the CHANGELOG 0.1.0 entry, the
feature matrix, the publish dry-run + package-policy file-list check,
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
verification set, the dry-run, the crate-qualified `alkssh-v0.1.0`
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
per-build binaries, the e2e tasks own new binaries + their own
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.
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
decomposition follows the alkgit pattern: `review-wave-5` stays a
pure review and `w5-publish` is its own task (the publish is a
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/common`) rather than extensions of the wave-3/4 mapping
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
`w5-fuzz-harness` (the alkgit pattern); the sidecar is excluded
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
family's multi-crate repos). Pinned in `w5-publish`; flagged for
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.
## 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
convention) and pushed to `origin/main`. Scope authority discharged:
the complete two-direction door shipped (server stack + all mappings,
client stack + op rows, key sources, host-key policy seam, the
decline matrix contract-tested per build per `scripts/matrix.sh` —
see AGENTS.md's verification list), the assembly stays out (seams
only — identity/ACL/fabric policy is downstream composition), no
decline matrix contract-tested per build per `scripts/matrix.sh`
(see AGENTS.md's verification list), the assembly stays out (seams
only; identity/ACL/fabric policy is downstream composition), no
binary, single crate. All five review gates closed green (waves 1–4
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
- `docs/architecture/` — overview, mapping, client, auth,
- `docs/architecture/`: overview, mapping, client, auth,
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
- Family ADR-003 (the structural decision); the alkgit implementation
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
(`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
resolves nothing). alkhttp's server resolves tokens through exactly
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.
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
identities is a contract invariant and a POC-1 checkpoint** (the
format is stable and self-describing, but the registration path is
assembly-owned, so the exact string compare must be verified, not
assumed).
3. `IdentityProvider::resolve_from_fingerprint(fp)` — `Some(identity)`
3. `IdentityProvider::resolve_from_fingerprint(fp): `Some(identity)`
→ `Result<Auth, _>::Authenticate { .. }`; `None` → decline
(`Reject`). The user name string is **ignored for identity** (it is
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:
`auth_succeeded(session)` attaches it to alkssh's per-session
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
mapped channel open exactly as in alkhttp (visible-surface =
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`
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
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-method disposition (fail-closed matrix)
| 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_none` | Decline (implement explicitly) | Fail-closed is ours to enforce |
| `auth_password` | Decline (implement explicitly) | No password concept in family identity |
| `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 |
| `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
(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
`auth_none`, `auth_password`, `auth_keyboard_interactive`,
`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
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
@@ -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
material comes from alkvault-backed sources, never env vars, never
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.
### 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`
→ absent → loud internal error, never an unauthenticated send). The
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
key at dial time; absent/locked vault → fail-closed decline, no
anonymous dial.
### 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
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
only, secrets are zeroized `Secret`s at the adapter, never env vars).
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
key bytes never leave the vault.
The bridge that does not exist yet, and is alkssh phase 0/1 design
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
with a passphrase) is the natural candidate; alkvault `encrypt`/
`decrypt` (`EncryptedData`) is the outer wrapper.
@@ -156,13 +156,13 @@ work:
(`decode_secret_key` for OpenSSH encoding) at dial/construction
time, held as a zeroized `Secret`-like value for the dial's
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
bearer-token-like secret is), so a one-time decode at the adapter
boundary is the honest shape. Lifecycle questions (zeroization
bounds, locked-vault behavior → fail-closed no-dial) are POC-2's
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
(`{ File, Memory }`-equivalent) backed by vault-encrypted material
at the assembly layer, mirroring alkhttp's capability injection.
@@ -171,14 +171,14 @@ work:
comparison; note alkvault's `encrypt`/`decrypt` speak `String` only,
so binary formats need a base64 layer); one-time
`decode_secret_key` at dial confirmed; ssh-key zeroizes ed25519 key
bytes on drop but the vault's `decrypt` returns a plain `String` —
the adapter owns plaintext zeroization at the seam (`Zeroizing`
bytes on drop but the vault's `decrypt` returns a plain `String`,
so the adapter owns plaintext zeroization at the seam (`Zeroizing`
wrap) [OQ seed]; the `Signer` seam is dismissed with evidence (no
provided impl in 0.64.1; works when hand-rolled; in-process vault
gains nothing). Locked/absent/tampered/rotation all fail closed
through distinct, mappable error shapes.
- **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
`IdentityProvider`. The server's own host key, though, is also a
held secret and uses the same at-rest model. Vault material is for
@@ -187,9 +187,9 @@ work:
### Host keys and `check_server_key`
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
`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
host fingerprints at the assembly layer; `check_known_hosts` exists in
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
`reverse-runner.md` and OQ-05 in the architecture tracker):** the
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
assembly time) → pinned, hard-decline on mismatch; (2) hosts that are
unknown until first contact (rental-fleet vanilla `sshd`) → the
@@ -214,16 +214,16 @@ policy-blind.
- alkcall: `src/core/auth.rs` (Identity/AuthToken/AuthContext/
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`
(`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
(auth-as-shared-core; the SshAdapter credential-table row), 006
(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)
- 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
relevant one; the derivation surface is not on this path),
`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
russh server-side entry point onto exactly one family protocol seam
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
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
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 |
|---|---|---|
| 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)` |
| 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,
make_reader/make_writer}` (`ChannelStream`/com halves). So each
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
request-level extras.
@@ -36,16 +36,16 @@ request-level extras.
| 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` → `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` → 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` → `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`/`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]** |
| `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) |
| `x11` | X forwarding | **never** (no feature) | — | 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 |
| `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 |
| `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 |
| `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
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
service and repo requires `git`; otherwise the alktty pipe/pty path
requires `session`. Parsing happens before dispatch and before any
resource allocation (a leaked channel alloc is a capability leak —
decline first). (Post-architecture: the former SOCKS-detect dispatch
arm is retired with the `dynamic-forward` feature — ADR-003 §1a /
resource allocation (a leaked channel alloc is a capability leak,
so decline first).. (Post-architecture: the former SOCKS-detect dispatch
arm is retired with the `dynamic-forward` feature, per ADR-003 §1a /
ADR-006 amendment; request-less session channels decline.) Grammar/
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
`channel_open_forwarded_tcpip`. Consumer-side alktunnels:
`TunnelListener`, `open_reverse_channel`; the forwarded data channel
maps to the `ForwardedHandler` route above. Dispositions follow the
`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.
## Request-level mapping on a `session` channel
@@ -83,33 +83,33 @@ spec detail deferred to phase 1.
| SSH request | Family mapping |
|---|---|
| `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) |
| `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.) |
| `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) |
| 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_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_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, 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
- russh windows: `Config::window_size`, `maximum_packet_size`,
`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
crate's own max-concurrent-channels (per session) and the per-length
parse limits on exec commands.
- alkcall bridge leg (when the family half is remote): channels-layer
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
multiplexing is why ADR-034 grants `alk/ssh` only stream_types
`[0, 1]` ("SSH multiplexes internally, including its own control").
- Honest advertisement: `Config::methods` mirrors implemented auth; an
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
@@ -126,7 +126,7 @@ decision:
`composed::direct_egress` is the family precedent for exactly this
bridging.)
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
inverts the door posture. Likely rejected on composability grounds;
recorded here for the ADR to decide explicitly.
@@ -136,17 +136,17 @@ decision:
Three alkcall ADR constraints collide productively:
- 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.
- 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`.
ADR-009 additionally sketches a browser route: a WebTransport session
to `/alk/ssh` where each bidi stream is handed to `SshAdapter::handle`
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
one end, mapped serving at the other), reused by the browser route.
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
- 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
wire-supplied; `wd`/`env` provenance).
- `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.
- OQ: username disposition (ignored entirely vs a routing hint).
- 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`
(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,
one correction to the phase 0 research docs, two API-shape facts, and
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;
crates.io `newest_version` re-verified during the POC), default
features off, crypto backend `ring`. Clean build under edition 2024,
rustc 1.99.0. The `ring` backend built with no external provisioning
— relevant data point for the pin ADR's crypto-backend question.
rustc 1.99.0. The `ring` backend built with no external provisioning,
a relevant data point for the pin ADR's crypto-backend question.
- Dependency tree confirms the pin considerations in `russh.md`:
`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
@@ -22,20 +22,20 @@ the OQ seeds this POC produced.
- Client side: russh client (`client::Handler`, `client::connect`) as
the primary programmatic client, plus a stub `IdentityProvider`
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.
- Vanilla client: OpenSSH 9.6p1 (`ssh`, `ssh -L`), isolated
`UserKnownHostsFile`, `BatchMode`, `IdentitiesOnly`.
## 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
`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
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
`resolve_from_fingerprint` string compare works with values that were
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
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
(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
probe-then-sign flow: the hook fires (key seen, ownership unverified)
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.
- 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`
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
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
therefore largely *inherited*; the family contract still requires
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.
### 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
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
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
`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
handled by simply not accepting the handle.
- 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
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
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
with the forward target.
- 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)
@@ -133,7 +133,7 @@ signed declined attempts and was disconnected with
auto-reject covers x11, direct-tcpip, forwarded-tcpip,
direct-streamlocal uniformly.
- `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`.
- **Client-side plumb caveat (recorded for POC-2/3):** `Channel::wait()`
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
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,
`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)
- "`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
probe-then-sign" — **confirmed** (and confirmed it doesn't inflate
probe-then-sign" is **confirmed** (and confirmed it doesn't inflate
the attempt counter).
- "Default auth impls are accept — every default must be overridden"
— **corrected**: at 0.64.1 only the offered hook defaults to accept.
- "Default auth impls are accept, so every default must be overridden"
is **corrected**: at 0.64.1 only the offered hook defaults to accept.
Explicit implementations still required by the family contract and
proven here.
- "`ChannelOpenHandle` drop = auto-reject; declines observable by
vanilla ssh; clean diagnostics" — **confirmed**.
- "`Channel` ↔ AsyncRead/AsyncWrite (`into_stream`) works" —
vanilla ssh; clean diagnostics" is **confirmed**.
- "`Channel` ↔ AsyncRead/AsyncWrite (`into_stream`) works" is
**confirmed**, with the wait-before-into_stream caveat.
## 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
send one).
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
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.
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.
## 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:
31 checks, 6 groups (fingerprint parity 2, russh client auth 9,
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.
## References
- `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
alkcall-shaped `IdentityProvider` in `authbridge` module)
- 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`
(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,
the inner-format comparison, the Signer-seam dismissal (now with
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**
(crates.io; its pinned surface: `EncryptedData`, `VaultServiceHandle`,
`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
— the two POCs exercised the same dependency graph).
re-exported through `russh::keys::ssh_key` (identical tree to POC-1's,
since the two POCs exercised the same dependency graph).
- Server side: POC-1's loopback shape verbatim (fingerprint registry
fed from `ssh-keygen -lf` output, stub `IdentityProvider`,
`into_stream` echo server, no `wait()` before `into_stream`).
- 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);
- `VaultKeyStore::load(&KeySource) -> Result<DialKey, LoadError>` —
decrypt-at-dial: `vault.decrypt(blob)` → plaintext wrapped in
- `VaultKeyStore::load(&KeySource) -> Result<DialKey, LoadError>`, the
decrypt-at-dial path: `vault.decrypt(blob)` → plaintext wrapped in
`Zeroizing<String>` at the seam → one-time
`russh::keys::decode_secret_key(&plain, None)` → fingerprint
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)
### 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**
(`VaultServiceHandle::encrypt(plaintext: &str, key_version: u32) ->
@@ -44,9 +44,9 @@ dialed:
| 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** |
| 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) |
| 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 |
| **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, 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 |
| 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
@@ -59,7 +59,7 @@ The **OpenSSH PEM inner wins** on honest grounds: ASCII natively (no
encode layer), self-describing (algorithm + public key + comment
embedded), vanilla-OpenSSH-tool-compatible at every boundary, and
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
bytes" works for every algorithm ssh-key can decode. This mirrors the
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
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
(16 b64 chars) — pure wire-format confirmation against the type's
(16 b64 chars), pure wire-format confirmation against the type's
documented shape.
- AES-256-GCM opacity: the ciphertext's `data` field contains no
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).
- Fresh IV + fresh salt per encrypt: re-encrypting the identical
plaintext produces entirely different blobs (no determinism trap for
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` →
`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
fingerprint. Lock/unlock cycles are side-effect-free on the key.
- **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
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`
`#[zeroize(drop)]`, mnemonic phrase via `Zeroizing`, `lock()`
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
adapter shape proven here wraps it in `Zeroizing<String>` at the
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.
### 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 |
|---|---|---|
| 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 |
| wrong mnemonic (unrelated seed) | `VaultServiceError::Encryption("decryption error: aead::Error")` — a *decryption* failure, distinguishable from `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 |
| 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 |
| `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 |
| 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
`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
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,
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.
### 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:
- **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,
ssh_key::PublicKey, hash_alg, &mut S)` accepts it. The doc comment
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
custom signer must be built by hand.
- **The wire contract is non-obvious and hand-rolled**: the flow is
probe (`has_sig=0`, `write_auth_request`) → server `USERAUTH_PK_OK`
→ client builds `session_id || userauth-prefix` (`client_make_to_sign`)
→ `Reply::SignRequest` carries the full buffer → the signer returns
**`to_sign || string{signature}`** — the original buffer with the
signature length-prefixed as an SSH string — and the client splices
**`to_sign || string{signature}`** (the original buffer with the
signature length-prefixed as an SSH string) and the client splices
`buffer[i0..]` as the packet payload. (First attempt appended the raw
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
happen inside russh before any handler hook, which is also a clean
fail-closed property worth noting for the mapping docs.)
- **Runtime proof that the seam works anyway**: `VaultSigner`
(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` +
`.encoded()` (i.e. `sig` → SSH wire blob) then length-prefixed into
the returned buffer.
- **Locked-vault fail-closed on the Signer path, too**: with the vault
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.
- **Verdict: dismissed for the vault path.** The vault is in-process:
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
keeping *more* plaintext copies alive (per-signature) rather than
fewer. The seam's genuine home is *external* signer processes
(ssh-agent), which the family treats as out of scope. The ADR can
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
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
`known_hosts` fingerprint (written by the vanilla client) matched
the vault host key's fingerprint exactly, and the russh client's
`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
known-hosts entry); only the server's *private* host key is held
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
client-key PEM written to a 0600 temp file was accepted by vanilla
`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
OpenSSH key material.
- 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.
## Hypothesis verdicts (pocs.md §POC-2)
- "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
seed-only variant as a size optimization; the ssh-key self-encrypted
variant recorded as a double-wrapping antipattern).
- "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
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
(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
convention; in-process vault gains nothing; runtime proof the seam
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
locked-at-bootstrap fail-closed.
## API facts for the ADRs (all verified against crates.io sources)
- **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>`;
`encrypt(&str, u32)` / `decrypt(&EncryptedData) -> Result<String>` /
`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
`BEGIN PRIVATE KEY` / `BEGIN ENCRYPTED PRIVATE KEY` PEM cases);
`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
apply at this pin). `PrivateKey: signature::Signer<Signature>`,
`Signature::encoded()` via `signature::SignatureEncoding`; russh's
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
path is `russh::keys::signature::Signer` + `russh::keys::ssh_encoding::Encode`.
- **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
established the "after verification" half; POC-2 adds "invalid sigs
never reach the handler at all").
@@ -255,7 +255,7 @@ with evidence". Evidence assembled:
## 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
*plaintext* is not until the adapter wraps it). Candidate fixes:
alkvault returns `Zeroizing<String>`, or the alkssh ADR documents
@@ -267,17 +267,17 @@ with evidence". Evidence assembled:
`InvalidPath`
for unknown
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
version-indexed rotation decision, alkvault ADR-021).
3. **One vault, many roles?** The POC held client dial keys *and* the
server host key in one vault (one mnemonic). Whether the assembly
layer should run separate vault instances per role (client vs
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
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
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**
(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
- `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`
(`client::Handle` authenticate methods — updated with the
(`client::Handle` authenticate methods, updated with the
Signer-verdict pointer)
- POC-1: `poc-1-auth-bridge.md` + `/workspace/alkssh-poc1`
(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`
(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`
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
0.64.1 runtime facts phase 1 must code around (the inline-dispatch
rule), the identity/scope live check, and the OQ seeds.
## 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
dependency tree as both prior POCs across all four stages.
- Sibling seams, published forms: **alkgit 0.1.0 +
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`),
**alksocks 0.3.1** (`local` feature — the shipped `tcp_dial`),
**alktunnels 0.4.1** (`local` feature — the shipped `local_dial`),
**alktty 0.8.1** (`local` feature — the shipped `LocalTtyBackend`
**alksocks 0.3.1** (`local` feature, the shipped `tcp_dial`),
**alktunnels 0.4.1** (`local` feature, the shipped `local_dial`),
**alktty 0.8.1** (`local` feature, the shipped `LocalTtyBackend`
over portable-pty), **alkcall 0.10.1** (channels + identity types).
- 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
@@ -33,7 +33,7 @@ rule), the identity/scope live check, and the OQ seeds.
- POC mechanics: single binary, stage functions returning check
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
@@ -47,7 +47,7 @@ vanilla git (GIT_SSH_COMMAND) → OpenSSH → russh server
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
in-memory object pool. The published alkgit seam needed no wrapper, no
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)
What real OpenSSH git actually sends: `git-upload-pack '/owner/demo'`
— single-quoted repo path with a leading `/` (the parse strips quotes
and the leading slash; no `\0host=` rides the SSH exec — the
What real OpenSSH git actually sends: `git-upload-pack '/owner/demo'`,
a single-quoted repo path with a leading `/` (the parse strips quotes
and the leading slash; no `\0host=` rides the SSH exec, so the
mapping-table's "same grammar as preamble.rs, without `\0host=`" is
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-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 |
### 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
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")`
both fail cleanly with a channel failure, with the finding recorded:
*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
loop.** Blocking inside `exec_request`/`pty_request`/`shell_request`
(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
immediately after the exec request). Confirmed by construction: the
serving entry is spawned and the handler returns immediately. This
is the binding implementation constraint for the alkssh adapter:
**every serving path is `tokio::spawn` + handler returns fast**.
- 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` /
`exit_status_request` / `exit_signal_request`. The exit-status
mapping row is implementable exactly as the table prescribes: send
`exit-status` before `close` from the serving task.
- The accepted-but-held `Channel` can be stored in the handler and
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).
### 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
`fatal: remote error: access denied: owner/absent`, exit 128.
- 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.
- The git-only build's decline matrix held under vanilla ssh:
`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-
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
@@ -129,32 +129,32 @@ Construction: server accepts the session channel, spawns the
domain-form targets). Client side: a local bridge listener pumps each
accepted TCP socket bidirectionally into one authenticated session
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.
### 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
SOCKS5 handshake *client-side* and then opens plain **`direct-tcpip`
channels** per connection. The `session` → SOCKS-in-channel row is
therefore the family-native client shape (an embedder/alktty-style
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
each serves. (POC evidence: this stage's proxy uses the session
channel; stage C's `-L`-style row serves forwarded and SOCKS-originated
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
session-channel dispatch modes the mapping table predicted, and both
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
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
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.
### 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
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 →
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
Route 1 (adapter-side bridge) exercised as the mapping table's route
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
`ChannelOperations` + `register_tunnel_direct_openable` with the
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
body byte-identically; **8 MiB through the bridge in ~333 ms**. The
`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
- Fabric open failure → the SSH channel is rejected **explicitly**
(`ChannelOpenHandle::reject(ConnectFailed)`); accept is possible
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
(exit 56) instead of a hang.
- The `-R` ingress direction is **declined** on this egress-only
build: `Handler::tcpip_forward` returns false (the global request
refuses), vanilla `ssh -R` dies with `remote port forwarding
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
POC-1 on this and the other stage builds.
### 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 channels layer (CF-005 propagation), and the establisher'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
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**
(STREAM_STDIN/STDOUT/STDERR/CTRL_IN/CTRL_OUT, 5-byte header + payload
— `alktty::wire`). The SSH mapping therefore wraps the channel with
(STREAM_STDIN/STDOUT/STDERR/CTRL_IN/CTRL_OUT, 5-byte header + payload;
`alktty::wire`). The SSH mapping therefore wraps the channel with
two small adapters: SSH data/EOF → stdin chunks (+ zero-length EOF
sentinel); alktty stdout chunks → SSH data, stderr chunks → SSH
`extended_data(1)`, ctrl-out exit chunk → SSH `exit-status` request +
close. `drive_session_pre_negotiated(writer, reader, NegotiateRequest
built locally, backends, ownership, identity)` — the exact producer
construct the mapping table names — then runs the real session. Proven
built locally, backends, ownership, identity)`, the exact producer
construct the mapping table names, then runs the real session. Proven
against a **real PTY** (`LocalTtyBackend` over portable-pty):
- `pty_request` args → `TerminalParamsWire` (`term`/cols/rows/
pixels; SSH modes → the reserved `{}` JSON — an alktty-side
reservation, alktty OQ-44; modes are deferred there) — live: bash on
pixels; SSH modes → the reserved `{}` JSON, an alktty-side
reservation, alktty OQ-44; modes are deferred there); live: bash on
the PTY answered `stty size` with the requested 24×80.
- Keystrokes ride in, PTY echo and shell output ride out (real bash
prompt captured in the run output).
- `window_change_request` → `ControlMessage::Resize` as a CTRL_IN
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.
- Pipe-mode `exec_request`: stdout rode SSH data, stderr rode SSH
`extended_data(1)` (client received both distinctly), exit
@@ -259,11 +259,11 @@ against a **real PTY** (`LocalTtyBackend` over portable-pty):
local backend produces.
- `signal(TERM)` → `ControlMessage::Signal` CTRL_IN chunk → the
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,
now with live wire evidence.
- 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.
### 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
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
verbatim from std's `ExitStatusExt` (`pipe.rs::exit_code_from` maps
`ExitStatus::signal()` to `−sig`; the alknet ADR-055 convention).
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.
- **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
`code = status.code().unwrap_or(1)` → **1** for signal-terminated
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
tests assert `code != 0`, never the encoding. Consequence: faithful
`exit-signal` for signal-killed PTY sessions needs the alktty
backend to conform to its own ADR's negative-code convention — an
alktty/portable-pty ask with tty/pty-grounded justification (upstream
backend to conform to its own ADR's negative-code convention,
an alktty/portable-pty ask with tty/pty-grounded justification (upstream
trackers; the alkssh mapping adapts automatically, no hedge needed).
- **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
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.
- **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
group (correct terminal semantics). The signal row's fidelity
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`);
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.
- `window_change`/`signal` are fire-and-forget (no want-reply), and
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.
- The server handler loop dispatches `CHANNEL_SUCCESS`-style replies
only through explicit handler calls; request-level failure replies
in stage A/D all used `Session::channel_failure`.
- 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
break-on-first-message trap twice; noted so the alkssh consumer
half doesn't).
## 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
(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
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
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).
- "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.
- "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
**translation-bridge shape** (two adapters, not a pump) do reshape
the session row's implementation notes.
@@ -345,7 +345,7 @@ published sources:
## Corrections recorded against phase 0 research
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
decline. Git over SSH is exec-only.
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
directions), not a byte pump; plus the exit/signal fidelity
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
1. Session-channel dispatch shape for SOCKS (first-byte sniff vs
in-band preamble vs exec marker; the detect window's bound).
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
alktty/portable-pty matter; pipe-mode pid-only vs PTY
process-group scope).
3. Shell command convention (already seeded; the POC's stand-in —
`bash` for shell, `sh -c <cmd>` for exec — is an explicit assembly
3. Shell command convention (already seeded; the POC's stand-in,
`bash` for shell and `sh -c <cmd>` for exec, is an explicit assembly
policy, not wire-derived).
4. The `-R` listen flow (forwarded-tcpip mapping): untouched by the
POC beyond the refusal shape; route choice (ingress via
@@ -381,7 +381,7 @@ published sources:
## 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;
second run byte-identical PASS set with different ephemeral ports).
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
- `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
loopback everything rides), `russh.md` (API inventory)
- 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
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
hypothesis wording below wherever they record corrections. POC source
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
(then branch mode per `docs/sdd_process.md`).
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.
## 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
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.
- Decline matrix: `auth_none`/`password`/`keyboard-interactive`
declined (0.64.1 correction: the *default impls already reject* at
this russh — only `auth_publickey_offered` defaults to accept;
`poc-1-auth-bridge.md` §3. The family contract still requires
implementing every hook explicitly — the pin is a one-way door);
this russh: only `auth_publickey_offered` defaults to accept;
`poc-1-auth-bridge.md` §3). The family contract still requires
implementing every hook explicitly, since the pin is a one-way door;
`Config::methods` advertises `publickey` only.
- `ChannelOpenHandle` drop = auto-reject semantics; unfeatured
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
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).
**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
secret: opaque bytes, vault-encrypted at rest, decrypted at use), what
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 /
absent (fail-closed no-dial) / mid-session unlock rotation.
- 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).
- Server and client host-key material riding the same model.
**Scope note:** the vault is for secrets *we* hold — client-side
outgoing keys and the server's own host key — and is explicitly not
**Scope note:** the vault is for secrets *we* hold (client-side
outgoing keys and the server's own host key) and is explicitly not
involved in resolving *clients'* fingerprints (that path is the
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
POC because its failure modes (at-rest/lifecycle, zeroization, lock
behavior) are unrelated to the auth-flow failure modes of POC-1.
**Hypothesized outcome:** OpenSSH-encoded key inside alkvault
`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
recorded, plus the russh inline-dispatch rule). POC source:
`/workspace/alkssh-poc3`.
**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?
**Construction:** standalone, but *depending on the real sibling
crates* (alktty/alksocks/alkgit + alkcall types via their published
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 →
`serve_duplex_resolved(...)` over the split channel halves with stub
`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
expectations over exec; channel EOF/close maps to session
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`
through the SOCKS5 proxy to a local HTTP endpoint. Confirms: the
dispatch-split inside `session` (parse-then-route vs SOCKS-detect),
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
real `ChannelClient` vs local-dial stand-in). Whichever route the
POC exercises informs the phase 1 ADR on the presence/absence of a
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
(`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
**translation bridge** (chunk framing both directions, not a byte
pump) and the exit/signal findings over the alktty exit-code
convention (pipe-path signal termination rides the negative code
`−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-
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`
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-side request mechanics — `Channel::exec()`, the `wait()`-drain
trap, fire-and-forget `window_change`/`signal` before `into_stream()`).
client-side request mechanics (`Channel::exec()`, the `wait()`-drain
trap, fire-and-forget `window_change`/`signal` before `into_stream()`)).
Two client-side facts phase 1 designs from:
1. **The acceptor rule.** The only server-initiated channel type in the
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
disposition (accept into a registered handler vs decline) is
alkssh's contract to define.
@@ -179,9 +179,9 @@ Two client-side facts phase 1 designs from:
## 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).
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.
4. Phase 0 completed when the findings docs existed and the OQ list in
`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
keys) is answered per POC-1/POC-2; scope question 2 (russh pin
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.
## 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)
- `russh.md` (API shapes the POCs exercise), `auth-bridge.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
that way; per repo license hygiene, reference code is for reading and
facts). Source:
`/workspace/@alkminer/alkminer` — specifically
`/workspace/@alkminer/alkminer`, specifically
`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
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
**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
pushes all work and all channel opens.
Security shape (why this beat the alternatives in practice):
- **No inbound surface on runners** — only `sshd` with one key.
- **Secrets never leave the hub** — the pattern's killer use was an
- **No inbound surface on runners**, only `sshd` with one key.
- **Secrets never leave the hub**; the pattern's killer use was an
OAI-compatible proxy served *by the hub* (`oai/mod.rs`, alkminer
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
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`,
runner→hub services via `-R` (the two legs below were both used,
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 |
| 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) |
| `-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
@@ -49,7 +49,7 @@ One authenticated SSH connection per instance carried:
client handler receives `server_channel_open_forwarded_tcpip` for
`-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
**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
served; otherwise decline cleanly. This is the client side of the
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
owns the forward. The family translation is an assembly-injected
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.
3. **Keepalives are load-bearing on cloud runners.** The hub needed
`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
unnoticed until a health-check exec failed. Evidence for the
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
(hub storage) and re-bound identically on reconnect; forwards were
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
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
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
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
security anchor is elsewhere: hub-held per-instance dial keys make
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
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
therefore knows beforehand), permissive accept where they can't
(rental fleets). The fleet churn is the reason the two-case policy
seam (OQ-05) is the correct shape rather than "pin everything".
6. **Per-runner key registration.** Keys were minted per instance and
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.
## 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
deployment; the crate's surface is channels and tunnels.
- 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.
## References
@@ -115,4 +115,4 @@ One authenticated SSH connection per instance carried:
`poc-3-mapping-bakeoff.md` §D.3 (client-request mechanics)
- `auth-bridge.md` §Host keys and `check_server_key` (the trust-policy
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
release notes as of 2026-10-25; versions re-verified at architecture
@@ -6,7 +6,7 @@ time before any pin is recorded.
## 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
(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
@@ -25,22 +25,22 @@ breaking changes are handler-signature shapes, not wire behavior.
GHSA-3hm4-p7h4-rvwp (pre-auth stack exhaustion via nested OpenSSH
certificates); the agent/pageant fixes are off our path (agent
forwarding declines, ADR-003).
- 0.64.1 (2026-10-05) was the POC pin — security-fix-only patch of
0.64.0 (GHSA-4wc5-f2rc-q74m — unbounded memory allocation in the
- 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
agent protocol server; no API change). The POC findings' evidence
and this file's earlier inventory facts were verified against
0.64.1 and carry to 0.65.0 as noted above.
- 0.64.0 (2026-10-04) broke API: dropped the
`legacy-ed25519-pkcs8-parser` feature (pre-0.43 non-compliant
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
`Handle::hostkeys_prove` and `Handle::send_global_request`; fixed
`Limits` not being fully enforced.
- There are older russh families (0.4x–0.5x) with materially different
handler shapes in circulation; one rough family-internal integration
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
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
trail is explicit.
- **MSRV 1.89, edition 2024.** Our crate's `rust-version` must be ≥
russh's once the dependency lands (currently `1.88` in the manifest
— a pending decision, deferred to the pin ADR).
russh's once the dependency lands (currently `1.88` in the manifest,
a pending decision, deferred to the pin ADR).
- **Crypto backend: exactly one of `aws-lc-rs` (default) or `ring`.**
The build errors if zero or both are selected. Feature choice is a
phase 1 decision (alktls family precedent may pull one way).
- Other features: `flate2` (compression, default), `rsa` (default;
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.
- **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),
@@ -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**
(`Auth::reject()`) for `auth_none`, `auth_password`,
`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-defaults assumption). Non-auth defaults decline/no-op. The
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:
- 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),
`auth_publickey(user, &PublicKey)` (called **after** russh verified
the signature and key ownership), `auth_openssh_certificate(user,
@@ -96,7 +96,7 @@ inventory:
`auth_succeeded(&mut Session)` fires on success.
- Channel opens (each receives `channel: Channel<Msg>` +
`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`,
`channel_open_direct_tcpip(host_to_connect, port_to_connect,
originator_address, originator_port)`,
@@ -119,12 +119,12 @@ inventory:
`lookup_dh_gex_group`.
**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
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
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` /
`exit_status_request` / `exit_signal_request` (send `exit-status`
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_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>`,
`auth_rejection_time` + `auth_rejection_time_initial`,
`max_auth_attempts`, `limits: Limits` (bytes/time before rekey),
`window_size`, `maximum_packet_size`, `channel_buffer_size`,
`event_buffer_size`, `inactivity_timeout`, `keepalive_interval`,
`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
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`;
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
fingerprint at the assembly layer, per the no-trust-on-first-use
direction this implies). Inventory: `check_server_key`
(&PublicKeyOrCertificate) → bool; `kex_done`; the full
server-initiated channel-open set (`server_channel_open_session /
_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
`ChannelOpenHandle` accept/reject semantics; `should_accept_unknown_server_channel`; data plane (`data`,
`extended_data`, `exit_status`, `exit_signal`, `xon_xoff`,
@@ -180,30 +180,30 @@ PrivateKeyWithHashAlg)`, `authenticate_password`,
`authenticate_none`, `authenticate_keyboard_interactive_start/
respond`, `authenticate_openssh_cert`, the `*_with` signer-injected
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
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
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
agent" implementation claim is stale), a custom signer must
replicate the splice convention (`to_sign || string{sig}`), and an
in-process vault gains nothing over one-time decode at dial —
see `poc-2-vault-keys.md` §4), `authenticate_gssapi_with_mic`,
in-process vault gains nothing over one-time decode at dial; see
`poc-2-vault-keys.md` §4), `authenticate_gssapi_with_mic`,
`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_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
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
declines); the session stays usable after declines. Channels expose
`wait() -> Option<ChannelMsg>`,
`split()`, `into_stream() -> ChannelStream` (AsyncRead + AsyncWrite),
`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/
window_change/signal/eof/close/exit_status`,
`writable_packet_size()`.
@@ -226,7 +226,7 @@ plus `PrivateKey`, `PublicKey`, `Certificate`, `HashAlg`,
(`load_secret_key`, `decode_secret_key`, `load_public_key`,
`parse_public_key_base64`, `load_openssh_certificate`,
`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]`
exists for host-key verification. OpenSSH agent interop (`keys::agent`)
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
candidate) and whether the RC-pinned `ssh-key`/`rsa`/`pkcs1` tree
is acceptable or gates the pin.
2. Crypto backend (`aws-lc-rs` vs `ring`) — align with alktls.
3. `rust-version` bump in our manifest (≥ 1.89) — lands with the
2. Crypto backend (`aws-lc-rs` vs `ring`); align with alktls.
3. `rust-version` bump in our manifest (≥ 1.89), landing with the
dependency.
4. Whether to enable the `rsa` feature at all (family keys are
ed25519-first; disabling `rsa` drops two RC deps from the tree).
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
- docs.rs russh 0.64.1: server `Handler`, client `Handler`, `Config`s,
`Handle`, `keys` module (sections above quote their inventories)
- 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
it)
@@ -265,10 +265,10 @@ build (`/tmp/opencode/alkssh-pin-check`, disposable):
| 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`) |
| 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 |
| 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 |
| MSRV | 1.89 (both 0.64.1 and 0.65.0) |
| 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
terminating the vanilla-ssh wire and bridging to/from alkcall channels
(family ADR-003). It is the alkhttp-style adapter for the SSH protocol
— the vanilla-ssh front door for vanilla clients, and the family's
(family ADR-003). It is the alkhttp-style adapter for the SSH protocol, the
vanilla-ssh front door for vanilla clients, and the family's
vanilla-ssh client where a family app dials an upstream SSH server.
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
serving a shell, tunnels, or socks. Every mapping is a cargo feature;
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.
## Guiding principles
@@ -36,10 +36,10 @@ not protocol invention.
party-upstream posture applies (file asks upstream; never silently
absorb a workaround).
2. **A door in both directions, vanilla on the outside.** alkssh is a
door for vanilla clients (server direction: vanilla in, family out
— each channel served by a family seam entry; primary current
door for vanilla clients (server direction: vanilla in, family
out, each channel served by a family seam entry; primary current
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
protocols, **alktty** for sessions, **alktunnels** for
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
model. A verified key's fingerprint → `Identity` via alkcall's
`IdentityProvider` is the only resolution path; secrets an alkssh
*client* holds come from alkvault-backed sources — vault-encrypted
at rest, treated as bearer-token-class secret material — never env
*client* holds come from alkvault-backed sources: vault-encrypted
at rest, treated as bearer-token-class secret material, never env
vars (family ADR-002 decision 5). The vault is a secret store on
this path, not a key generator or key custodian.
## 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).
- **Being an sshd.** No local user accounts, no `authorized_keys` file
semantics of the OpenSSH variety, no PAM-shaped anything. Deployment
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.
- **A binary / CLI.** Downstream apps embed the crate; there is no
`alkssh` executable in this crate.
@@ -90,26 +90,25 @@ not protocol invention.
These frame the research docs; each is a candidate open question for
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
stream_types `[0, 1]` ("SSH multiplexes internally, including its
own control"), and ADR-009 sketches a browser-WASM route where the
ALPN transports an SSH stream to an `SshAdapter`. But family ADR-003
describes alkssh terminating vanilla SSH at an edge. Whether
`alk/ssh` carries raw vanilla-SSH bytes between alkssh instances
(a relay leg), serves the browser route, or both — and how that
interacts with ADR-042's "the hub never runs a handler for
`alk/ssh`" relay ruling — is unresolved (see `mapping-table.md`
§topology).
2. **russh pin** — version, crypto-backend feature, and the
describes alkssh terminating vanilla SSH at an edge. It remains
unresolved whether `alk/ssh` carries raw vanilla-SSH bytes between
alkssh instances (a relay leg), serves the browser route, or both,
and how that interacts with ADR-042's "the hub never runs a handler
for `alk/ssh`" relay ruling (see `mapping-table.md` §topology).
2. **russh pin**: version, crypto-backend feature, and the
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).
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 →
identity contract; client-side vault-backed at-rest key handling
(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
(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
5. **Safe Exit**: Always have a way to unblock progress when stuck
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
cost-benefit framework in taskgraph's framework docs.
@@ -37,7 +37,7 @@ problems need investigation.
4. Document learnings
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:
- **Branch mode** (POC builds on code in this repo): git branch off main,
@@ -56,11 +56,11 @@ approaches
**Process**:
1. Architect creates the architecture documentation structure:
- `docs/architecture/README.md` — index with doc table, ADR table, lifecycle
- `docs/architecture/overview.md` — package purpose, exports, dependencies
- `docs/architecture/<component>.md` — one focused doc per component/area
- `docs/architecture/decisions/` — numbered ADR files (ADR-001, ADR-002, ...)
- `docs/architecture/open-questions.md` — centralized tracker with OQ-IDs
- `docs/architecture/README.md`: index with doc table, ADR table, lifecycle
- `docs/architecture/overview.md`: package purpose, exports, dependencies
- `docs/architecture/<component>.md`: one focused doc per component/area
- `docs/architecture/decisions/`: numbered ADR files (ADR-001, ADR-002, ...)
- `docs/architecture/open-questions.md`: centralized tracker with OQ-IDs
2. All docs start in `Draft` status. Spec docs reference ADRs by number (not
inline rationale) and OQs by number (not inline questions).
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
- Centralize open questions in `open-questions.md` with OQ-IDs
- 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
- Iterate based on review feedback
**Deliverables**:
- `docs/architecture/README.md` — index with doc table, ADR table, lifecycle
- `docs/architecture/<component>.md` — focused spec documents
- `docs/architecture/decisions/` — numbered ADR files
- `docs/architecture/open-questions.md` — centralized OQ tracker
- `docs/architecture/README.md`: index with doc table, ADR table, lifecycle
- `docs/architecture/<component>.md`: focused spec documents
- `docs/architecture/decisions/`: numbered ADR files
- `docs/architecture/open-questions.md`: centralized OQ tracker
---
@@ -191,17 +191,17 @@ sessions.
**Mode**: Primary (manages worktrees and agent sessions)
**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
limited set (current, notify, status). No mode toggle required.
**Tools**:
- `worktree({action, args})` — spawn, sessions, dashboard, message, abort,
- `worktree({action, args})`: spawn, sessions, dashboard, message, abort,
cleanup
- Bash (opencode CLI for session interaction)
- 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)
**Key Behaviors**:
@@ -230,10 +230,10 @@ limited set (current, notify, status). No mode toggle required.
**Tools**:
- Read, Write, Edit, Glob, Grep, Bash
- `worktree({action: "notify", ...})` — report progress/blockers to coordinator
- `worktree({action: "current"})` — verify worktree assignment
- `worktree({action: "notify", ...})`: report progress/blockers to coordinator
- `worktree({action: "current"})`: verify worktree assignment
- webSearch (documentation lookup)
- `memory` / `memory_compact` — context management (via @alkdev/open-memory,
- `memory` / `memory_compact`: context management (via @alkdev/open-memory,
when available)
**Key Behaviors**:
@@ -340,8 +340,8 @@ mode: scratch project outside the repo)
- Create minimal POCs to validate hypotheses
- Branch mode: work in isolated research worktrees; standalone mode: work
in the scratch project only
- Document findings and recommendations (findings are the deliverable —
POC source is disposable and never merges to main)
- Document findings and recommendations (findings are the deliverable; POC
source is disposable and never merges to main)
- Timebox strictly - abandon if taking too long
- Be honest about limitations and blockers
@@ -404,7 +404,7 @@ Implement OAuth2 authentication with provider abstraction.
### Categorical Estimates
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`,
`impact`, or `level` is a red flag indicating incomplete decomposition. See the
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)|
| 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`:
- `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:
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`).
2. A file in `tasks/` **without frontmatter is silently skipped** by
`taskgraph validate`, `list`, and every graph command — no error,
the file just doesn't exist as far as the graph is concerned.
`taskgraph validate`, `list`, and every graph command. It produces
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
frontmatter block (`id`, `name` required; `depends_on` plus the
five categorical fields expected). If you need a plain prose
tracker, keep it under `docs/` instead.
3. `status` transitions run through the lifecycle only: `pending` →
`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.
### Task Lifecycle
@@ -472,15 +473,15 @@ checks and review passes will hit.
### 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
(`planning, decomposition, implementation, review, research`) already covers
architecture activities:
| 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. |
| Decomposing a spec into ADRs + OQs | `decomposition` | Direct fit — breaking a large architecture area into decideable units. |
| 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. |
| Architecture review pass | `review` | Direct fit. |
| Research / POC before a decision | `research` | Direct fit. |
| 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
external-trigger tracker task represents the unblocking condition. It is
tagged `[external-trigger, deferred-oq]`, has `risk: trivial` and
`level: research` (it is not actionable work — it tracks whether the
external condition has arrived), and its `id` is referenced from the OQ's
`level: research` (it is not actionable work; it tracks whether the
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`
tools can reason about it, and downstream work that depends on the
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
`deferred(scope)` to `open` (or directly to `resolved` if no architecture
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.
## Review Injection
@@ -559,8 +560,8 @@ Use graph analysis to determine where reviews should happen:
### Current (open-coordinator plugin)
The Coordinator uses the `worktree` tool from the open-coordinator opencode
plugin. It's a single tool with `{action, args}` dispatch — no separate
enable/toggle steps. Role is auto-detected from session state.
plugin. It's a single tool with `{action, args}` dispatch, so there are no
separate enable/toggle steps. Role is auto-detected from session state.
```
1. Identify parallel work
@@ -613,13 +614,13 @@ a worktree.
When the open-memory plugin is available alongside open-coordinator, the
coordinator gains:
- `memory({tool: "children", args: {sessionId: "..."}})` — view sub-agent
- `memory({tool: "children", args: {sessionId: "..."}})`: view sub-agent
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
- `memory({tool: "context"})` — check context window usage before long
- `memory({tool: "context"})`: check context window usage before long
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
`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
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.
## Document Structure
@@ -710,7 +711,7 @@ Accepted | Proposed | Deprecated | Superseded
(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.
### 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
- **Priority**: high | medium | low
- **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
```
+5 -5
View File
@@ -109,7 +109,7 @@ dependencies = [
[[package]]
name = "alkssh"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"alkcall",
"alktty",
@@ -146,9 +146,9 @@ dependencies = [
[[package]]
name = "alktty"
version = "0.8.1"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2265167842b7d3ea09bc235f48450d3dcf22049a2ca71f8155de00acbf97c226"
checksum = "9cab9b0d3d6c1bdef2622799e3b95bb6063720153a124a57f4c60f95f3d42ea8"
dependencies = [
"alkcall",
"async-trait",
@@ -166,9 +166,9 @@ dependencies = [
[[package]]
name = "alkvault"
version = "0.1.0"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52e032b074f7fea71ea15528994c5b3a6503c082c519045bb3b0519281291918"
checksum = "b2522ded10a506770779e72063422057bb63f1b75457c1742a3362072adfb1c4"
dependencies = [
"aes-gcm",
"base64",
+16 -16
View File
@@ -1,16 +1,16 @@
# 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
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
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.
## Layout
- `fuzz_targets/` — one binary per target; thin `fuzz_target!` wrappers.
- `shared/` — the invariant logic, as a plain library so normal
- `fuzz_targets/`: one binary per target; thin `fuzz_target!` wrappers.
- `shared/`: the invariant logic, as a plain library so normal
`cargo test` (stable toolchain) can replay the committed corpora
through the same invariants (`#[cfg(test)]` modules; quinn's CI
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
in `src/dispatch.rs`, the key-decode arm `decode_key_arm` 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
themselves, never forks; the fingerprint ingress is fully public API)
and must never become a dependency of the root crate.
- `corpus/<target>/` — committed hand-made seeds (regenerate with
`python3 fuzz/gen_fuzz_seeds.py` from the repo root — must be a no-op
diff on the committed set). Only `seed-*` files are tracked; grown
- `corpus/<target>/`: committed hand-made seeds (regenerate with
`python3 fuzz/gen_fuzz_seeds.py` from the repo root; the result must
be a no-op diff on the committed set). Only `seed-*` files are tracked; grown
corpora (SHA-named units), artifacts, and coverage output are
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
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
| 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 |
| `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 |
| `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 |
| `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) |
| `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; 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
`cargo fuzz build` and `cargo fuzz run` must be executed with `fuzz/`
(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
# build (nightly, pinned by rust-toolchain.toml inside fuzz/; run from fuzz/)
+2 -2
View File
@@ -1,7 +1,7 @@
#![no_main]
//! The exec-grammar target (`mapping.md` §Session-channel dispatch —
//! the git-parseable exec grammar + the dispatch parse path) — thin
//! The exec-grammar target (`mapping.md` §Session-channel dispatch,
//! the git-parseable exec grammar + the dispatch parse path): a thin
//! wrapper over the shared invariant battery.
use libfuzzer_sys::fuzz_target;
+2 -2
View File
@@ -1,7 +1,7 @@
#![no_main]
//! The fingerprint-format target (ADR-004 decision 1 — the `SHA256:` +
//! unpadded base64 path) — thin wrapper over the shared invariant
//! The fingerprint-format target (ADR-004 decision 1, the `SHA256:` +
//! unpadded base64 path): a thin wrapper over the shared invariant
//! battery.
use libfuzzer_sys::fuzz_target;
+2 -2
View File
@@ -1,7 +1,7 @@
#![no_main]
//! The key-decode target (ADR-005 decision 2 — the decrypt-at-use
//! chain's `decode_secret_key` seam) — thin wrapper over the shared
//! The key-decode target (ADR-005 decision 2, the decrypt-at-use
//! chain's `decode_secret_key` seam): a thin wrapper over the shared
//! invariant battery.
use libfuzzer_sys::fuzz_target;
+2 -2
View File
@@ -1,8 +1,8 @@
#![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
//! modes JSON) — thin wrapper over the shared invariant battery.
//! modes JSON): a thin wrapper over the shared invariant battery.
use libfuzzer_sys::fuzz_target;
+1 -1
View File
@@ -1,7 +1,7 @@
//! The corpus-replay plumbing: the committed seeds replay through the
//! invariant batteries on the stable toolchain (`cargo test
//! --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.
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,
//! `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
//! ingress shape is the handler's: the per-length cap
//! (`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`);
//! everything behind the cap is this battery: a byte string →
//! `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
/// 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).
fn spec_parse(command: &str) -> ExecRoute {
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
/// 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
/// unparseable is the pipe row).
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
/// single-quote-wrapped (a non-empty inner ending in a quote) or
/// unquoted absolute — normalized by stripping the quotes, one
/// The argument grammar re-derived: a path-shaped argument, either
/// fully single-quote-wrapped (a non-empty inner ending in a quote) or
/// unquoted absolute, normalized by stripping the quotes, one
/// leading `/`, and one trailing `.git` suffix (an empty-after-strip
/// prefix keeps the suffix; the git row's validation declines the
/// empties, never a silent emptying).
+4 -4
View File
@@ -1,9 +1,9 @@
//! The fingerprint format ingress (ADR-004 decision 1,
//! `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
//! 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.
//!
//! 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
//! standard base64 contract shape (one `:` separator, 43-char body,
//! 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
//! (`tests/vectors/`) are replayed by the corpus-gate tests below.
@@ -98,7 +98,7 @@ fn decode_unpadded(body: &str) -> Option<Vec<u8>> {
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 {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
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
//! 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
//! ([`alkssh::decode_key_arm`], the `#[doc(hidden)]` exposure that is
//! `decrypt_and_decode`'s exact decode stage, not a fork).
//!
//! Invariants: no-panic on arbitrary plaintext shapes (the production
//! plaintext is the vault's `String` — valid UTF-8 by return type; the
//! battery shapes arbitrary bytes into that form via the lossy
//! conversion and drives it through the same `Zeroizing` wrap the
//! adapter seam applies) and arbitrary passphrase shapes (the
//! underlying decode's passphrase arm — the production chain always
//! plaintext is the vault's `Zeroizing<String>`, valid UTF-8 by
//! return type; the battery shapes arbitrary bytes into that form via
//! the lossy conversion and drives it through the same zeroized
//! wrap the vault's `decrypt` return applies, alkssh 0.2.0 seam) and
//! arbitrary passphrase shapes (the
//! underlying decode's passphrase arm; the production chain always
//! 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
//! failure text never carries the plaintext (the assertable half of
//! the Zeroizing posture — the plaintext exists solely inside the
//! call, dropped zeroized; no retention, no static cache — and the
//! typed error's only payload is the decode machinery's message).
//! the Zeroizing posture: the plaintext exists solely inside the
//! call, dropped zeroized, with no retention and no static cache; the
//! 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
//! 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
//! 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
//! 256-byte run). A plaintext-`contains`-message check in the other
//! 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
/// [`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
/// module docs; the two assertions are jointly airtight).
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
/// [`KeySourceError::Decode`] refusal (the inner-format contract —
/// [`KeySourceError::Decode`] refusal (the inner-format contract:
/// decision 1, never a fallback).
#[test]
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
//! modes): the `pty_request` args → `TerminalParamsWire` conversion
//! 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).
//!
//! The harness's framed feed (deterministic, seed-friendly):
//! `term_len` (u32 LE, bounded to the remaining input) + the term
//! bytes + four u32 LE dimensions (`cols`/`rows`/pixel width/height,
//! zero-defaulted when the input runs out) + an arbitrary trailing
//! mode blob. The mode blob is *never parsed* — the SSH ttymodes ride
//! the reserved `{}` JSON (OQ-44, backends ignore modes in v1) — so
//! mode blob. The mode blob is *never parsed*; the SSH ttymodes ride
//! the reserved `{}` JSON (OQ-44, backends ignore modes in v1), so
//! the battery's totality over arbitrary mode blobs is by
//! construction; the framing demonstrates it (the conversion's
//! outcome never varies with the blob).
//!
//! Invariants: no-panic on any term string (empty, unicode, control
//! 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
//! truncation; `term` is preserved verbatim; the reserved-modes JSON
//! 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).
fn take_u32(cursor: &mut &[u8]) -> u32 {
if cursor.len() >= 4 {
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# 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 /
# --all-features; the sftp-only run is the no-op-feature fact itself
# 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.
//!
//! 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
//! load means **no server is constructed**: the error surfaces as
//! [`AdapterError::Construction`] with the original [`KeySourceError`]
@@ -11,34 +11,35 @@
//! host-key source contract's [`KeySourceError::Absent`] shape).
//!
//! 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
//! `server_supported()`), the loaded host keys ride `Config::keys`,
//! and the [`Limits`] knobs map one-for-one:
//! `window_size` / `maximum_packet_size` / `channel_buffer_size` /
//! `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
//! russh 0.65's own default posture (`Config::default()`, left
//! un-touched deliberately): the rekey `Limits` stay russh-enforced at
//! their RFC-4253 §9 defaults, `auth_rejection_time` stays russh's
//! 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
//! explicit non-ed25519 decline (`w2-auth-bridge`).
//!
//! The mount ([`SshAdapter::handle`], ADR-001's contract): a generic
//! 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
//! `alk/ssh` pin is a ledger entry; nothing here speaks it). The
//! in-flight framing is the alkhttp door precedent: `handle` is an
//! async per-stream call whose completion is the session's end, so the
//! assembly's accept loop mounts per stream via `tokio::spawn` — the
//! serving loop itself stays spawned inside russh (ADR-006 decision 4,
//! which `run_stream` already satisfies: the session's packet loop
//! leaves on its own task), and the mount's return type stays
//! `Result<(), AdapterError>` — no russh type rides the signature.
//! assembly's accept loop mounts per stream via `tokio::spawn`, and
//! the serving loop itself stays spawned inside russh (ADR-006
//! decision 4, which `run_stream` already satisfies: the session's
//! packet loop leaves on its own task), and the mount's return type
//! stays `Result<(), AdapterError>` (no russh type rides the
//! signature).
use std::sync::Arc;
@@ -53,7 +54,7 @@ use crate::handler::SessionHandler;
#[cfg(feature = "tunnel")]
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`).
///
/// Built by [`SshAdapter::new`] from the assembly-supplied
@@ -67,8 +68,8 @@ pub struct SshAdapter {
}
impl SshAdapter {
/// Constructs the adapter — decrypt-at-construction of the host
/// key set (ADR-005 decision 6): a failed or empty load is no
/// Constructs the adapter: decrypt-at-construction of the host
/// key set (ADR-005 decision 6); a failed or empty load is no
/// server, with the [`KeySourceError`] refused through
/// [`AdapterError::Construction`].
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
/// surface: the mount's behavior is observable on the wire, never
/// through a russh type leak.
@@ -108,7 +109,7 @@ impl SshAdapter {
}
/// 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
/// this call's task; the assembly's accept loop mounts per 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
/// default posture (see the module docs; ADR-002 surface notes).
fn build_server_config(serve: &ServeConfig, keys: Vec<PrivateKey>) -> russh::server::Config {
@@ -160,7 +161,7 @@ mod tests {
const NOOP: 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
/// `tests/common` double module is the tests/-side owner; src-unit
/// 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
//! ([`crate::handler`]) and enforced at every `channel_open_*` accept
//! path (`overview.md` §Bounded-resources policy: "concurrent channels
//! per session — ours (in-crate) + per-identity policy when bridged";
//! AGENTS.md convention 9 — SSH servers are internet-facing; unbounded
//! per session (ours, in-crate) + per-identity policy when bridged";
//! AGENTS.md convention 9: SSH servers are internet-facing; unbounded
//! loops and unbounded buffers are bugs).
//!
//! ## Shape
@@ -11,12 +11,12 @@
//! [`ChannelCap`] is a per-session count of active channels. Admission
//! ([`ChannelCap::admit`]) is the check-and-increment the accept paths
//! 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
//! [`crate::dispatch::cap_exceeded_open_decline`]). Release
//! ([`ChannelCap::release`]) fires from the `channel_close` hook for
//! established channels — and, since `w3-tunnel-direct`, from a
//! spawned route-1 bridge task on a fabric-open failure — and, since
//! established channels, and, since `w3-tunnel-direct`, from a
//! spawned route-1 bridge task on a fabric-open failure, and, since
//! `w3-tunnel-listen`, from the spawned `-R` arrival pumps on the
//! never-established paths (client-rejected open, dead session): see
//! the release-sites section.
@@ -27,36 +27,36 @@
//! Every admission has exactly one release, and the paths are
//! 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
//! established; a declined open never established one, and a close
//! is consumed at most once (russh removes the channel before the
//! 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
//! holds the count across the fabric open; on fabric failure the
//! open is rejected with the explicit `ConnectFailed`
//! (ADR-006 decision 1: accept only after the fabric open
//! 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
//! 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).
//! 3. **The `-R` arrival pumps** (`w3-tunnel-listen`): a fabric
//! arrival is admitted into the owning route's count and opens the
//! SSH `forwarded-tcpip`/`forwarded-streamlocal` channel toward
//! the vanilla client through the session handle. A rejected open —
//! the client's own refusal, or a dead session's send failure —
//! the vanilla client through the session handle. A rejected open
//! (the client's own refusal, or a dead session's send failure)
//! never establishes the channel, so the count releases inside the
//! arrival task exactly once (an established channel's release is
//! 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)
//!
//! 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
//! (`w3-tunnel-direct`) and the arrival admission (`w3-tunnel-listen`)
//! both break the single-writer property: those tasks are alkcall
@@ -66,12 +66,12 @@
//! critical sections are single integer ops (no await inside the
//! lock), and all sites saturating. Channels never closed before
//! 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;
/// 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
/// spawned route-1 bridge tasks ([`Arc<ChannelCap>`]) exactly so the
/// fabric-failure release can fire from the task that knows it.
@@ -82,13 +82,13 @@ pub(crate) struct ChannelCap {
impl ChannelCap {
/// 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`
/// 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
/// 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.
pub(crate) fn admit(&self, cap: usize) -> bool {
let mut active = self.active.lock();
@@ -110,7 +110,7 @@ impl ChannelCap {
*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)]
pub(crate) fn active(&self) -> usize {
*self.active.lock()
@@ -136,7 +136,7 @@ mod tests {
}
/// - *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]
fn release_recovers_the_cap() {
let cap = ChannelCap::default();
@@ -150,7 +150,7 @@ mod tests {
}
/// - *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).
#[test]
fn zero_cap_refuses_every_open() {
@@ -177,9 +177,9 @@ mod tests {
/// - *Contract stamp:* `w3-tunnel-direct`'s recorded release
/// 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
/// `Arc` — and disjoint releases never double-count.
/// `Arc`), and disjoint releases never double-count.
#[test]
fn the_cap_is_shareable_between_the_loop_and_bridge_tasks() {
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
//! [`crate::adapter`]'s mount: the russh client stack every wave-4 op
//! row composes into.
@@ -7,7 +7,7 @@
//! whole authenticated-dial sequence, **fail-closed at every step**:
//!
//! 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
//! **no-dial outcomes** ([`DialError::KeySource`], the variant
//! preserved). The decoded key lives for this dial; the vault is
@@ -15,18 +15,18 @@
//! established connections streaming, POC-2 §2).
//! 2. **The bounded russh client `Config`** ([`russh_client_config`])
//! 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
//! **always `Some`** (`inactivity_timeout` — the crate never
//! **always `Some`** (`inactivity_timeout`, the crate never
//! constructs a session with no clock), and the window/packet/
//! channel-buffer knobs verbatim. Everything else stays russh's
//! own default posture (the ADR-002 surface notes, mirrored from
//! [`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
//! auth-attempt knob exists at this pin and none is needed: the
//! 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
//! the handler ([`crate::client_handler`]): the crate default
//! [`RejectAll`](crate::RejectAll) connects to nothing; a
@@ -34,23 +34,23 @@
//! Declines surface as [`DialError::HostKeyRejected`].
//! 4. **Publickey-only authentication** (the family contract):
//! 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
//! exposed through [`SshConnection`]); a refusal is
//! exposed through [`SshConnection`])); a refusal is
//! [`DialError::AuthRejected`] and the attempt is closed cleanly.
//!
//! **The connection handle** ([`SshConnection`]) is the type
//! 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
//! policy, limits and endpoint labels carried. The connection handle's
//! clean-close path is [`SshConnection::close`]; the listener handles'
//! 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
//! 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
//! primitives; the crate provides bound ports, handles, cancellation,
//! 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
//! 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
//! disconnect message. So the surface never relies on drop timing
//! 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
//! server double, both directions of the boundary: abrupt-on-drop,
//! 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
//! 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::
//! ReverseListener) handles (the actually-bound endpoint carried;
//! `cancel()` the wire-honest teardown), the connection handle
@@ -84,7 +84,7 @@
//! [`russh_client_config`] (the bounded-knob mapping, asserted
//! verbatim) and [`ClientHandler`](crate::client_handler::ClientHandler)
//! (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
//! (the *server*-direction vehicle) only hosts the crate-to-crate
//! smoke (see the task's smoke-posture record).
@@ -102,7 +102,7 @@ use crate::client_handler::ClientHandler;
use crate::config::{DialConfig, Limits};
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
/// four-step sequence and their fail-closed outcomes.
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
/// `host`/`port` decision context. Malformed targets refuse before
/// 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))
}
/// 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
/// default posture (see the module docs). The mapping is the dial
/// task's `bounded-config wiring observable`;
@@ -199,15 +199,15 @@ pub fn russh_client_config(limits: &Limits) -> russh::client::Config {
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
/// handle"): the russh `client::Handle` wrapped with the crate's
/// 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).
///
/// 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
/// wire-visible teardowns stay explicit calls on top of that shared
/// stack (the module docs' §Lifecycle posture: drop is never a
@@ -216,13 +216,13 @@ pub struct SshConnection {
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
/// wire surface is the wrapped methods, never the russh stack itself
/// (`serve.rs`'s wrapped-surface pattern mirrored client-side).
///
/// 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
/// the last holder (row-pinned), and a live `ReverseListener` (its
/// `cancel()` composes on this stack) keeps the wire serving until
@@ -231,18 +231,18 @@ pub struct SshConnection {
pub(crate) struct ConnectionShared {
/// The russh handle the connection and the listener handles
/// 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) limits: Limits,
pub(crate) address: String,
pub(crate) username: String,
/// 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
/// [`crate::client_accept`]`'s recorded decisions.
#[cfg(feature = "tunnel")]
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/
/// wire-error paths (the cap relation recorded in
/// [`crate::client_accept`]; [`crate::channel_cap`]'s mutex makes
@@ -271,7 +271,7 @@ impl SshConnection {
/// The `-R` leg's acceptor registry (the
/// `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
/// late-install dynamic). The dial constructs it empty: nothing
/// server-initiated is served until a slot holds, fail-closed at
@@ -281,14 +281,14 @@ impl SshConnection {
&self.shared.acceptors
}
/// The reverse-listen count's shared handle — the op rows
/// ([`crate::client_accept`]) read it; private to the crate.
/// The reverse-listen count's shared handle, which the op rows
/// ([`crate::client_accept`]) read; private to the crate.
#[cfg(feature = "tunnel")]
pub(crate) fn listener_budget(&self) -> &ChannelCap {
&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
/// the crate.
#[cfg(feature = "tunnel")]
@@ -296,7 +296,7 @@ impl SshConnection {
&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,
/// so the field stays private to this module).
#[cfg(any(feature = "session", feature = "git", feature = "tunnel"))]
@@ -306,7 +306,7 @@ impl SshConnection {
&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
/// drives the command-carrying rows' client-mirror gate).
#[cfg(any(feature = "session", feature = "git"))]
@@ -320,12 +320,12 @@ impl SshConnection {
}
/// The explicit clean-close path (the lifecycle primitive,
/// [`russh::Disconnect::ByApplication`] — the honest close): the
/// peer observes a clean disconnect, no half-open channels —
/// [`russh::Disconnect::ByApplication`], the honest close): the
/// peer observes a clean disconnect, no half-open channels;
/// every open channel is torn down with the session (row-pinned
/// through the server double). **Drop is not this path** (the
/// 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.
pub async fn close(self) {
let _ = self
@@ -373,7 +373,7 @@ mod tests {
}
/// - *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.
#[test]
fn bounded_config_mapping_carries_the_default_postures() {
@@ -385,7 +385,7 @@ mod tests {
assert_eq!(
config.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!(
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
//! 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
//! (`reverse_listen_unix` / `reverse_listen_unix_cancel`): the
//! vanilla-server listen requests, the lifecycle primitives
//! (`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
//! `cancel()`); the stateless wire cancel forms ride beside them
//! (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
//! russh handler routes **server-initiated `forwarded-*` opens**
//! through. Served only under a registered acceptor for that type
//! 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
//! reverse-runner fact 1 disposition is ours).
//!
//! ## 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
//! (exactly the `-R` data leg's two: `forwarded-tcpip` /
//! `forwarded-streamlocal`, both under `tunnel`); one trait per type
@@ -35,70 +35,70 @@
//! `ForwardedAcceptor` vocabulary is grown, not forked: the payload
//! fields are the same shapes (the open's originator fields + the
//! channel duplex), the duplex now family-typed
//! ([`TunnelChannel`] — the `-L` rows' byte
//! ([`TunnelChannel`], the `-L` rows' byte
//! duplex, one type both legs ride).
//! - **Install is refuse-never-replace** ([`AcceptorRegistry::
//! 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
//! never a quiet displacement of a live acceptor. Uninstall is
//! explicit, 1:1 with the install.
//! - **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`]
//! ([`SshConnection::acceptors`]) — the dial constructs it empty,
//! ([`SshConnection::acceptors`]): the dial constructs it empty,
//! the assembly installs/uninstalls whenever it decides. The open
//! hooks read the slot **at arrival time**, fail-closed: an open
//! 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
//! open's bytes would have to be buffered unboundedly to accept-
//! 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
//! is a half-capability (the peer stays bound, every arrival
//! declines), so a `reverse_listen` with no registered acceptor for
//! the type answers
//! [`OpError::NoAcceptor`](crate::OpError) **before any wire op**
//! (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).
//! - **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
//! template): the registry is bounded by construction (two fixed
//! slots). The per-connection **listener count** is a dedicated
//! 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
//! 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
//! set the registry would have to serve). The per-connection
//! **accepted-open handoff count** is the second dedicated counter
//! against the same ceiling (admitted in the dial handler's open
//! 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
//! 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
//! channel's transport bounds are russh's windows, and the slot read
//! is the arrival admission).
//! - **The listener handles (`w4-lifecycle` — the handles'
//! - **The listener handles (`w4-lifecycle`, the handles'
//! 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
//! 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
//! 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);
//! (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
//! same wire op the stateless forms issue;
//! (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
//! listener count exactly once on a confirmed cancel (the wire
//! state machine governs: the second cancel is the unknown-listener
@@ -107,37 +107,37 @@
//! *listen* rows (`reverse_listen_unix` / `reverse_listen_unix_cancel`)
//! compile everywhere and
//! 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
//! 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.
//! - **`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
//! 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 data leg
//!
//! 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
//! 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
//! 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
//! duplex is wire-clean (russh closes the channel).
//!
//! ## Test surface
//!
//! 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
//! forwarded channels toward the client per accepted connection, and
//! pumps them (a real `ssh -R`-shaped round trip in-process, the task's
//! recorded smoke posture). The full-circle alkssh-client ↔
//! alkssh-server-direction smoke (both `-R` legs through the alkcall
//! 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
//! territory, never silently dropped.
//!
@@ -163,24 +163,24 @@ use crate::client::SshConnection;
use crate::error::OpError;
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
/// [`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
/// (the arrival's peer, as the wire carried it) + the channel duplex.
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).
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`.
pub connected_port: u32,
/// The arrival's peer address (the wire originator).
pub originator_address: String,
/// The arrival's peer port (the wire originator).
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).
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
/// channel duplex).
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).
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).
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
/// packet loop): the assembly's pump/home of the open lives inside
/// it; the crate releases its handoff counter when the handoff task
/// ends — the call returning *or the task unwinding*
/// (`HandoffCapGuard`) — so the family shape (hand off to a queue,
/// ends (the call returning *or the task unwinding*
/// `HandoffCapGuard`), so the family shape (hand off to a queue,
/// return) keeps the counter's meaning "open accepted, handoff in
/// flight".
#[async_trait]
pub trait TcpipForwardedAcceptor: Send + Sync {
/// Serves one accepted open. Never invoked for an unregistered
/// 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
/// open-decline.
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).
#[async_trait]
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).
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
/// recorded sibling): the slot already holds a live acceptor; a second
/// 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")]
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
/// recorded decisions). Constructed empty at the dial
/// ([`SshConnection::acceptors`], the live-connection accessor);
@@ -263,7 +263,7 @@ pub struct 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).
pub fn new() -> Self {
Self {
@@ -273,7 +273,7 @@ impl AcceptorRegistry {
}
/// 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).
pub fn install_tcpip(
&self,
@@ -287,7 +287,7 @@ impl AcceptorRegistry {
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
/// accepted keep pumping (their handoff happened); arrivals after
/// this decline at arrival time (fail-closed, no buffering).
@@ -315,7 +315,7 @@ impl AcceptorRegistry {
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.
pub(crate) fn tcpip_acceptor(&self) -> Option<Arc<dyn TcpipForwardedAcceptor>> {
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 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
/// unwind*: a panicking assembly acceptor (or any panic in the handoff
/// 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
/// hook runs in the packet loop and returns fast): accepts the open on
/// the wire, converts the channel to the family duplex, delivers the
/// 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
/// recorded release site — the count's meaning is "accepted, handoff
/// recorded release site; the count's meaning is "accepted, handoff
/// in flight").
#[cfg_attr(feature = "tunnel", allow(clippy::too_many_arguments))]
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);
}
/// 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`].
pub(crate) async fn handoff_forwarded_streamlocal(
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
/// alkminer restore flow: the *actually bound* values to persist and
/// rebind) and the cancel forms' raw wire match key (sshd parity: a
@@ -439,8 +439,8 @@ pub enum ReverseEndpoint {
}
impl ReverseEndpoint {
/// The actually-bound port — the tcp shape's; `None` for the unix
/// shape (no port semantics — the cancel matches by the path).
/// The actually-bound port, the tcp shape's; `None` for the unix
/// shape (no port semantics; the cancel matches by the path).
pub fn bound_port(&self) -> Option<u32> {
match self {
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 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()`.
///
/// **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
/// connection's end removes it. The handle does not borrow the
/// connection: the cancel composes on the shared wire stack (the
@@ -469,18 +469,18 @@ pub struct 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
/// the surfaced number).
pub fn endpoint(&self) -> &ReverseEndpoint {
&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,
/// sshd parity; the accept loop ends, the bound port frees) and
/// 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
/// [`OpError::ListenRefused`](crate::OpError). The handle is
/// 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
/// 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).
async fn cancel_reverse_tcp(
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
/// answers before the wire — see the module docs' gate record).
/// answers before the wire; see the module docs' gate record).
#[cfg(unix)]
async fn cancel_reverse_unix(
shared: &crate::client::ConnectionShared,
@@ -552,28 +552,28 @@ async fn cancel_reverse_unix(
impl SshConnection {
/// 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`
/// §Lifecycle: the bound-port primitive, the port-0 mint); for an
/// 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.
///
/// Gates, both **before any wire op**: the `forwarded-tcpip`
/// 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
/// listener count must have room
/// ([`OpError::ListenCapExceeded`](crate::OpError) — the
/// ([`OpError::ListenCapExceeded`](crate::OpError), the
/// dedicated counter sharing
/// [`Limits::max_concurrent_channels`](crate::Limits); see the
/// module docs' cap relation). The peer's refusal is
/// [`OpError::ListenRefused`](crate::OpError) (the wire's
/// `RequestDenied`) and releases the count; the connection stays
/// 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
/// triggered by a drop — the module docs' recorded decision).
/// triggered by a drop; the module docs' recorded decision).
pub async fn reverse_listen(
&self,
address: impl Into<String>,
@@ -608,10 +608,10 @@ impl SshConnection {
/// The reverse-listen cancel row (`-R` teardown): the explicit
/// 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
/// shared path; a confirmed cancel releases the listener count —
/// never twice, the wire's unknown-listener refusal governs the
/// shared path; a confirmed cancel releases the listener count
/// (never twice; the wire's unknown-listener refusal governs the
/// repeats). The peer's refusal (unknown listener) is
/// [`OpError::ListenRefused`](crate::OpError).
pub async fn reverse_listen_cancel(
@@ -630,7 +630,7 @@ impl SshConnection {
/// [`SshConnection::reverse_listen`] for the
/// `forwarded-streamlocal` slot, plus the **runtime-Unix gate**
/// (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).
#[cfg_attr(not(unix), allow(unused_variables))]
pub async fn reverse_listen_unix(
@@ -667,7 +667,7 @@ impl SshConnection {
}
/// 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()`).
#[cfg_attr(not(unix), allow(unused_variables))]
pub async fn reverse_listen_unix_cancel(
@@ -708,7 +708,7 @@ mod tests {
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
/// displacement of a live acceptor); uninstall frees it.
#[test]
@@ -736,7 +736,7 @@ mod tests {
}
/// - *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.
#[test]
fn the_slots_are_independent() {
@@ -789,7 +789,7 @@ mod tests {
}
/// - *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
/// assembly acceptor can never strand a held slot past the
/// 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
//! [`crate::handler`]'s server-direction handler.
//!
@@ -9,46 +9,46 @@
//! the crate's single fingerprint path ([`crate::fingerprint`]),
//! labels it with the dial target (host + port, parsed from the
//! dial address), and hands the whole [`PresentedHostKey`] to the
//! injected [`HostKeyPolicy`] — the verdict rides straight through
//! (`Ok(policy.decide(..).await)`). The crate is policy-blind; the
//! injected [`HostKeyPolicy`], and the verdict rides straight
//! through (`Ok(policy.decide(..).await)`). The crate is policy-blind; the
//! built-in [`RejectAll`](crate::RejectAll) verdict makes every dial
//! refuse; a `false` verdict is the decline disposition russh
//! surfaces as its `UnknownKey` wire class
//! ([`DialError::HostKeyRejected`](crate::DialError)
//! — never an accept, never a retry).
//! ([`DialError::HostKeyRejected`](crate::DialError)),
//! never an accept, never a retry).
//! 2. **Server-initiated channel opens declined cleanly** (ADR-003 §4,
//! 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
//! explicit-over-inherited. The `forwarded-tcpip` /
//! `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
//! acceptor for that type and under the `tunnel` feature (the
//! [`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
//! [`ChannelOpenFailure::AdministrativelyProhibited`] — a clean
//! [`ChannelOpenFailure::AdministrativelyProhibited`], a clean
//! wire-visible refusal the peer observes as an
//! unconfirmed open, never an accepted-then-dropped channel. The
//! accepted open's handoff spawns (the spawn-and-return rule's
//! client mirror): the hook accepts and returns fast, the duplex +
//! 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
//! ([`Handler::should_accept_unknown_server_channel`]) answers
//! `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
//! (x11, agent) decline at any configuration.
//!
//! The accepted-open handoff carries a **bounded bookkeeping**
//! counter (`AGENTS.md` convention 9, the cap relation recorded in
//! [`crate::client_accept`]): a dedicated per-connection counter —
//! sharing the dial's `Limits::max_concurrent_channels` ceiling the
//! server direction's per-session cap uses — admitted in the hook
//! [`crate::client_accept`]): a dedicated per-connection counter
//! (sharing the dial's `Limits::max_concurrent_channels` ceiling the
//! server direction's per-session cap uses), admitted in the hook
//! 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
//! counter declines the arrival cleanly
//! (`cap-exceeded`).
@@ -79,7 +79,7 @@ use crate::{DialError, fingerprint};
/// the two forwarded hooks route through, plus the accepted-open
/// handoff counter and its ceiling (the dial's
/// `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")]
pub(crate) struct TunnelSurface {
acceptors: Arc<AcceptorRegistry>,
@@ -87,7 +87,7 @@ pub(crate) struct TunnelSurface {
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
/// (`#[doc(hidden)]` re-export from the crate root): the contract
/// tests drive the bare hooks, exactly the
@@ -96,7 +96,7 @@ pub struct ClientHandler {
policy: std::sync::Arc<dyn HostKeyPolicy>,
host: String,
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
/// default posture in the featured builds too).
#[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
/// 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
/// [`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
/// carries why.
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
/// 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
/// acceptor / no room → the clean decline, never accept-and-drop.
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
/// gate here; the listen rows carry the OQ-11 gate on the bind —
/// the recorded asymmetry, see [`crate::client_accept`]).
/// gate here; the listen rows carry the OQ-11 gate on the bind,
/// the recorded asymmetry (see [`crate::client_accept`]).
async fn server_channel_open_forwarded_streamlocal(
&mut self,
channel: Channel<Msg>,
@@ -350,8 +350,8 @@ impl russh::client::Handler for ClientHandler {
Ok(())
}
/// A never-feature row (ADR-003 §5): x11 has no family seam — the
/// decline is not even registry territory.
/// A never-feature row (ADR-003 §5): x11 has no family seam, so
/// the decline is not even registry territory.
async fn server_channel_open_x11(
&mut self,
channel: Channel<Msg>,
@@ -368,7 +368,7 @@ impl russh::client::Handler for ClientHandler {
}
/// 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(
&mut self,
channel: Channel<Msg>,
@@ -384,7 +384,7 @@ impl russh::client::Handler for ClientHandler {
/// Wire-reachable only if
/// [`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
/// even then.
async fn server_channel_open_unknown(
@@ -415,7 +415,7 @@ mod tests {
/// - *Contract stamp:* ADR-004 decision 6 posture mirror (the
/// 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
/// `PresentedHostKey { host, port }` verbatim, and the
/// 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
//! drives (the hub dialed [`SshConnection`](crate::SshConnection) and
//! now opens every channel itself). **Type symmetry at the seam is
//! 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
//! rows, alkgit's `Service` for the git row) and the surfaces are
//! crate-owned (`RemoteSession` / `TunnelChannel` / `GitPackSession`),
//! the failure taxonomy [`OpError`](crate::OpError). All rows share
//! 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
//! 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,
//! `continue`-ing past the interleaved rest, and maps a `Failure`
//! to [`OpError::RequestRefused`](crate::OpError). The drain is
//! message-capped (bounded resources: a peer that never answers
//! cannot loop it unboundedly; the *connection's* wall clock — the
//! dial's always-set [`Limits::inactivity_timeout`](crate::Limits) —
//! cannot loop it unboundedly; the *connection's* wall clock, the
//! dial's always-set [`Limits::inactivity_timeout`](crate::Limits),
//! bounds the idle waits).
//! - **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
//! connection stays usable after the decline** (row-pinned per
//! decline class).
//! - **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
//! 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,
//! 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
//! spawned task draining `wait()`), the shape the server
//! direction's git row landed for the same reason
//! (`w4-git-exit-close`). The exit-ride is the family plane's (§the
//! session rows) or the consumed-report oneshot's (§the git row).
//! - `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.
//!
//! ## The family plane (the session rows)
//!
//! 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:
//! 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 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
//! name→`Sig` map, `Eof` → wire EOF); wire `data` → stdout chunks,
//! wire `extended_data(1)` → stderr chunks; and the exit rides
//! **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)` →
//! `−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
//! 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
//! ttymodes never ride the wire pty request (the OQ-44
//! 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`] |
//! | 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`] |
//! | 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
//! 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):
//! [`OpError::CommandTooLong`](crate::OpError) before any wire op.
//!
//! No client-side in-crate channel cap *admits* ops (the server
//! 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,
//! 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.
//!
//! ## Spawn posture
//!
//! The op methods await their launch (typed open, requests, the
//! reply drains) on the *caller's* task — never the russh packet
//! loop's — so the server direction's spawn-and-return deadlock class
//! reply drains) on the *caller's* task, never the russh packet
//! 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
//! 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
//! their duplex halves drop).
@@ -117,13 +117,13 @@ use crate::client::SshConnection;
use crate::client_handler::ClientHandler;
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),
/// keeping every pump's backpressure bounded.
#[cfg(any(feature = "session", feature = "git"))]
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`).
#[cfg(feature = "git")]
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`
/// opens (the SSH originator fields are what a vanilla client fills
/// 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).
#[cfg(feature = "tunnel")]
const TUNNEL_ORIGINATOR: &str = "alk-ssh";
/// The wire exit status → the family `i32` exit-code convention: a
/// 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).
#[cfg(any(feature = "session", feature = "git"))]
fn family_exit_code(status: u32) -> i32 {
@@ -155,7 +155,7 @@ fn family_exit_code(status: u32) -> i32 {
/// op taxonomy (a channel-open failure →
/// [`OpError::OpenDeclined`](crate::OpError::OpenDeclined),
/// 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
/// connection stays usable after every decline (row-pinned).
#[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,
/// 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,
/// not repeated), message-capped. `Failure` is the wire
/// 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 {
/// 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**
/// (never a half-opened subsystem channel). The feature exists
/// 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
/// *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> {
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
/// directions, exactly the plane the server direction's session
/// 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).
#[cfg(feature = "session")]
enum LaunchRequest {
@@ -255,11 +255,11 @@ enum LaunchRequest {
#[cfg(feature = "session")]
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
/// chunk-framed session (§the family plane; stderr separated).
/// 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> {
let command = command.into();
if command.len() > self.budgets().max_exec_command_len {
@@ -272,7 +272,7 @@ impl SshConnection {
/// carries the family
/// [`alktty::negotiation::TerminalParamsWire`](alktty::negotiation::
/// 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.
pub async fn pty_exec(
&self,
@@ -292,8 +292,8 @@ impl SshConnection {
}
/// The shell row ("own a shell on the runner"): with
/// `params` — the pty mode (the interactive `ssh` shape); with
/// `None` — the pipe-mode shell (the `ssh -T` shape; a pty-less
/// `params`, this is the pty mode (the interactive `ssh` shape); with
/// `None`, the pipe-mode shell (the `ssh -T` shape; a pty-less
/// shell request is legitimate vanilla and served, never declined,
/// mirroring the server direction's decided parity).
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
/// *once*, then the idle-for-kill state (the session keeps pumping);
/// 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")]
async fn session_input_pump(
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
/// (stdin `0` / `CTRL_IN` `3`); no negotiation-error frames exist
/// toward the door, so the read is the protocol's direct
/// [`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
/// the pump's [`HubFrame::Break`].
#[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
/// plane (§the family plane). `data` → stdout chunks,
/// `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
/// no report → no exit chunk; the output half just ends.
#[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
/// 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.
#[cfg(feature = "session")]
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
/// 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")]
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
/// open's `into_stream()` conversion; downstream crates see the
/// type opaque as ever).
@@ -595,11 +595,11 @@ impl tokio::io::AsyncWrite for TunnelChannel {
#[cfg(feature = "tunnel")]
impl SshConnection {
/// 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
/// class, and the connection stays usable (row-pinned per
/// 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
/// data pump is the raw byte duplex).
pub async fn tunnel_direct(
@@ -618,7 +618,7 @@ impl SshConnection {
}
/// 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
/// declines cleanly ([`OpError::UnixUnavailable`](crate::OpError))
/// 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
/// channel untouched), the sshd-side stderr split out, and the
/// consumed exit report at the family's `i32` exit-code convention
/// (`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
/// no-exit-chunk close family's mirror).
#[cfg(feature = "git")]
@@ -666,7 +666,7 @@ pub struct GitPackSession {
/// the pack).
pub output: tokio::io::DuplexStream,
/// 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).
pub stderr: tokio::io::DuplexStream,
/// The consumed exit report (see the type docs).
@@ -683,7 +683,7 @@ impl std::fmt::Debug for GitPackSession {
#[cfg(feature = "git")]
impl SshConnection {
/// 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
/// inverse; no forked grammar), pointed at the vanilla `sshd`:
/// typed open + `exec`, the pack duplex pumped, the exit report
@@ -695,9 +695,9 @@ impl SshConnection {
/// [`OpError::InvalidRepoId`](crate::OpError);
/// the constructed command is checked against the exec-length
/// budget ([`crate::Limits::max_exec_command_len`]) →
/// [`OpError::CommandTooLong`](crate::OpError) — both **before any
/// wire op**. (The grammar's other decline classes —
/// `unknown-service` / `service-only` — are structurally
/// [`OpError::CommandTooLong`](crate::OpError), both **before any
/// wire op**. (The grammar's other decline classes,
/// `unknown-service` / `service-only`, are structurally
/// unreachable here: the [`alkgit::Service`] input type carries
/// only the two serving services; the construction arm can emit
/// no other token.)
@@ -793,7 +793,7 @@ async fn git_input_pump(
/// The git row's **output pump**: the wire → the pack duplex +
/// 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
/// hub's receive reads the no-report mirror).
#[cfg(feature = "git")]
@@ -848,7 +848,7 @@ mod tests {
use russh::ChannelOpenFailure;
/// - *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
/// pins the op rows' own consumption point).
#[test]
@@ -870,7 +870,7 @@ mod tests {
}
/// - *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).
#[cfg(any(feature = "session", feature = "git"))]
#[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
//! only: russh `Config` construction is wave-2 material, dialing lands
//! at wave 4; nothing here touches the wire or the vault.
//!
//! Two directions, one budget model (`overview.md` §Public surface):
//!
//! - [`ServeConfig`] — the server direction's assembly-supplied serving
//! - [`ServeConfig`]: the server direction's assembly-supplied serving
//! configuration: the OQ-08 fields ([`ServeConfig::shell_argv`] /
//! [`ServeConfig::exec_wrapper_argv`]), the injected identity,
//! host-key and tunnel handles, the 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;
//! [`GitSeam`] carries the alkgit backend quartet + the caller-owned
//! pipeline + the push-options flag), the OQ-09 `cwd`/`env` values,
//! 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,
//! host-key policy, and the bounded-resource knobs (carried by
//! [`Limits`]).
@@ -22,7 +22,7 @@
//! Crate rules this module encodes:
//!
//! - **Unset-declines, no crate defaults** (OQ-08): the OQ-08 fields
//! are `Option` and their unset semantics live in the type shape —
//! are `Option` and their unset semantics live in the type shape,
//! an unset field makes its row decline with a clean error at
//! request time (wave-2/3 consumption), never a fallback, never
//! construction-time validation (`error.rs`'s no-config-error
@@ -33,10 +33,10 @@
//! [`ServeConfig::git_seam`] unset → the git row declines even
//! though `git` is on (the decline matrix's config-driven notes;
//! the feature alone configures nothing). The tunnel branch carries
//! its flat `Option<Arc<ChannelClient>>` — an egress-only
//! (route-1-only) door needs no struct for leg 1 — and the `-R`
//! its flat `Option<Arc<ChannelClient>>`, because an egress-only
//! (route-1-only) door needs no struct for leg 1, and the `-R`
//! 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
//! [`TunnelListenSeam`]: unset → the listen rows decline even with
//! 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
//! leaves [`ServeConfig::channel_client`] absent.
//! - **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
//! assembly can build from these types.
//!
//! Knob-name parity with the russh 0.65 pin (ADR-002):
//! `window_size`, `maximum_packet_size`, `channel_buffer_size`,
//! `inactivity_timeout`, `keepalive_interval`, `keepalive_max`,
//! `max_auth_attempts` — wave-2/wave-4 map these fields into the russh
//! `max_auth_attempts`; wave-2/wave-4 map these fields into the russh
//! `Config` verbatim. russh's rekey `Limits` stay russh-enforced at
//! its own RFC-4253 §9 defaults (wave-2); they are not carried here —
//! this module's [`Limits`] is the crate's in-crate + mapped-knob
//! its own RFC-4253 §9 defaults (wave-2); they are not carried here,
//! since this module's [`Limits`] is the crate's in-crate + mapped-knob
//! budget model.
use std::collections::BTreeMap;
@@ -84,15 +84,15 @@ use crate::key_source::{HostKeySource, KeySource};
/// The bounded-resources budget model (`overview.md`
/// §Bounded-resources policy): the in-crate caps plus the russh-knob
/// mapping with the documented default postures. Every session —
/// server and client direction — carries one (`AGENTS.md` convention
/// mapping with the documented default postures. Every session,
/// server or client direction, carries one (`AGENTS.md` convention
/// 9); there is no unbounded configuration shape.
///
/// Carried by value (`Copy`): a [`Limits`] is plain tuning data, and
/// wave-2/4 copies it into the russh `Config` construction.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Limits {
/// The crates' own cap on concurrent channels per session —
/// The crates' own cap on concurrent channels per session;
/// opened channels beyond the cap are declined (fail-closed
/// refuse, never queue). The per-identity policy layer (when the
/// alkcall bridge carries it) is additional and comes with the
@@ -102,39 +102,39 @@ pub struct Limits {
pub max_concurrent_channels: usize,
/// The parse-input cap for the wave-3 exec grammar: a wire exec
/// command string longer than this declines before any parse or
/// resource allocation (decline-first, alloc-never — the ADR-006
/// resource allocation (decline-first, alloc-never, the ADR-006
/// dispatch contract). Assembly-tunable.
///
/// Default: `DEFAULT_MAX_EXEC_COMMAND_LEN`.
pub max_exec_command_len: usize,
/// Maximal number of allowed authentication attempts — mapped to
/// Maximal number of allowed authentication attempts, mapped to
/// russh's `max_auth_attempts` (server direction). Default `10`
/// per ADR-004 decision 6: the OpenSSH counting `none`-probe
/// consumes one attempt, so `10` leaves `IdentitiesOnly`-style
/// clients at least nine real key attempts.
pub max_auth_attempts: usize,
/// The initial channel receive window — mapped to russh's
/// The initial channel receive window, mapped to russh's
/// `window_size` (per-channel memory bound).
///
/// Default: 2 MiB (russh's own default posture, kept).
pub window_size: u32,
/// The maximal size of a single SSH packet — mapped to russh's
/// The maximal size of a single SSH packet, mapped to russh's
/// `maximum_packet_size` (per-channel record-size bound).
///
/// Default: 32 KiB (russh's own default posture, kept).
pub maximum_packet_size: u32,
/// Per-channel unprocessed-message buffer — mapped to russh's
/// Per-channel unprocessed-message buffer, mapped to russh's
/// `channel_buffer_size` (the backpressure bound); messages past
/// it propagate backpressure to the TCP stream, never an
/// unbounded buffer.
///
/// Default: 100 (russh's own default posture, kept).
pub channel_buffer_size: usize,
/// The session wall clock — the connection is garbage-collected
/// The session wall clock; the connection is garbage-collected
/// after this long with no activity. Mapped to russh's
/// `Option<inactivity_timeout>` as always-set (`Some(value)`) in
/// 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.
///
/// Default: `DEFAULT_INACTIVITY_TIMEOUT`.
@@ -142,13 +142,13 @@ pub struct Limits {
/// Client-direction keepalive interval (cloud fleets): the
/// dead-peer probe, mapped to russh's `keepalive_interval`.
///
/// Default: `Some(15 s)` — the reverse-runner-evidence posture
/// Default: `Some(15 s)`, the reverse-runner-evidence posture
/// (OpenSSH `ServerAliveInterval=15` parity): with russh's own
/// default (`None`) a dead NAT'd connection sits unnoticed until
/// an exec health check fails (`reverse-runner.md` lesson 3).
/// `None` is available to the assembly but is not the posture.
pub keepalive_interval: Option<Duration>,
/// How many unanswered keepalives close the connection — mapped
/// How many unanswered keepalives close the connection, mapped
/// to russh's `keepalive_max`.
///
/// 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
/// 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`
/// 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
/// half-supplied seam states and the unset-declines contract reads as
/// one `Option`: **`ServeConfig::session_seam` unset → the session
/// rows decline even though the `session` feature is on** (the
/// 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).
#[cfg(feature = "session")]
#[derive(Clone)]
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 assembly**: alkssh never touches a real backend, and the
/// `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).
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
/// (`w3-tty-session`'s negotiation rows; the wire command/args
/// never reach it). Assembly-supplied, never wire-derived.
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
/// layer riding alongside; an alkcall type, no new dep).
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):
/// 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
/// 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
/// never invents it). Same whole-`Option`-struct decision as
/// [`SessionSeam`]:
/// **`ServeConfig::git_seam` unset → the git row declines even though
/// the `git` feature is on**, checked at request time by the
/// mappings — no construction-time validation.
/// mappings; no construction-time validation.
#[cfg(feature = "git")]
#[derive(Clone)]
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
/// 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`
/// verbatim (`w3-git-mapping`). Carried `Copy`; [`alkgit::Limits`]'s
/// own defaults are the assembly's baseline.
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
/// (the collapsed-generic-denial posture lives in alkgit).
pub registry: Arc<dyn GitRegistry>,
@@ -278,13 +278,13 @@ pub struct GitSeam {
/// The pack generation + negotiation-ack trait
/// (`generate`/`common_haves`).
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).
pub ingest: Arc<dyn GitPackIngest>,
/// 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,
/// 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
/// the serving direction takes* (the caller keeps its own handle;
/// `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
/// `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
/// assembly-supplied `Arc<ChannelClient>` alone cannot carry the
/// 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
/// (`tcpip_forward` / `streamlocal_forward` / both cancel forms)
/// 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
/// time by the mappings; no construction-time validation. The
/// 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
/// half-configured adapter).
#[cfg(feature = "tunnel")]
@@ -336,7 +336,7 @@ pub struct TunnelListenSeam {
/// The **shared** per-connection `OperationRegistry` the fabric
/// leg's inbound ops dispatch against: the exact `Arc` the
/// 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
/// reads it per call (the W2 post-hoc-registration posture). The
/// door's own ACL entry rides the registration; arrivals lacking
@@ -355,7 +355,7 @@ use alkcall::registry::registration::OperationRegistry;
impl TunnelListenSeam {
/// Builds the seam over the assembly's shared per-connection
/// 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 {
Self { registry }
}
@@ -372,22 +372,22 @@ impl std::fmt::Debug for TunnelListenSeam {
/// The server direction's assembly-supplied serving configuration
/// (`overview.md` §Server stack): everything wave-2's serving loop
/// takes at construction. Pure data + injected handles — no russh
/// takes at construction. Pure data + injected handles; no russh
/// `Config` is built here (wave 2), no serving loop exists yet.
///
/// Construction carries no validation beyond the required handles:
/// the unfeatured/unset capability surfaces are structural `Option`s
/// that decline at request time (the fail-closed contract) — see
/// that decline at request time (the fail-closed contract); see
/// [`ServeConfig::shell_argv`] and [`ServeConfig::channel_client`].
pub struct ServeConfig {
/// OQ-08 field 1: the shell argv, run verbatim for
/// `shell_request` (`NegotiateRequest.cmd`). No crate default —
/// `shell_request` (`NegotiateRequest.cmd`). No crate default:
/// **unset → `shell_request` declines with a clean error, never a
/// fallback**: the crate is not an sshd and has no account model
/// from which a shell could be derived, so any baked-in shell
/// would be deployment policy smuggled into the crate. A
/// `session` build with this unset serves neither shell nor exec
/// requests (the git row is unaffected — parsing never needs a
/// requests (the git row is unaffected, since parsing never needs a
/// shell).
///
/// 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.
/// `["/bin/sh", "-c"]`). A non-git `exec_request`'s wire command
/// string is appended as the final argv element
/// (`wrapper ++ [cmd]`) — vanilla `ssh host "cmd"` quoting
/// (`wrapper ++ [cmd]`), the vanilla `ssh host "cmd"` quoting
/// 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**.
///
/// Set to `Some(vec)` to serve the pipe-exec row (feature-gated
/// at wave 2: only under the `session` feature).
pub exec_wrapper_argv: Option<Vec<String>>,
/// 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
/// family config). Unset → the backend-side default applies.
pub session_cwd: Option<PathBuf>,
/// The OQ-09 posture's assembly-supplied environment for
/// `NegotiateRequest.env` — **never wire-derived**. Empty by
/// `NegotiateRequest.env`, **never wire-derived**. Empty by
/// default.
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
/// `drive_session[_pre_negotiated]` rows take). **Unset → the
/// session rows decline even though the `session` feature is on**
/// (the decline matrix's config-driven note; the feature alone
/// 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")]
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
/// flag the `serve_duplex_resolved` row takes). **Unset → the git
/// row declines even though the `git` feature is on** (same
@@ -430,33 +430,33 @@ pub struct ServeConfig {
#[cfg(feature = "git")]
pub git_seam: Option<GitSeam>,
/// 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
/// holds no identity model.
pub identity_provider: Arc<dyn IdentityProvider>,
/// The injected host-key source (the wave-1
/// [`HostKeySource`] seam, decrypt-at-construction per ADR-005
/// decision 6) — feeds wave-2's `Config::keys`; a load failure
/// decision 6) feeds wave-2's `Config::keys`; a load failure
/// means no server is constructed, never a half-configured one.
pub host_key_source: Arc<dyn HostKeySource>,
/// The tunnel route-1 bridge's family-fabric entry: a held
/// alkcall `ChannelClient` (`TunnelSession::open_direct` runs on
/// it). **Absent → every tunnel row declines with a clean error
/// even under the `tunnel` feature** (the feature alone configures
/// nothing — the fabric handle is the row's other half). The
/// nothing; the fabric handle is the row's other half). The
/// present handle is still gated by the `tunnel` feature at wave
/// 2: absent + unfeatured both decline; present + featured
/// serves.
pub channel_client: Option<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
/// forwarded opens dispatch against). **Unset → the listen rows
/// 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).
#[cfg(feature = "tunnel")]
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
/// present (the registration site). Crate-private: never assembly
/// data.
@@ -469,10 +469,10 @@ pub struct ServeConfig {
impl ServeConfig {
/// 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
/// 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.
pub fn new(
identity_provider: Arc<dyn IdentityProvider>,
@@ -500,14 +500,14 @@ impl ServeConfig {
/// Whether the `shell_request` row is configurable at all in this
/// configuration (`Some` under the `session` feature): `false`
/// means the row declines — the unset-declines contract, checked
/// means the row declines; the unset-declines contract, checked
/// at request time by the wave-2 dispatch, never here.
pub fn shell_configured(&self) -> bool {
self.shell_argv.is_some()
}
/// Whether the non-git `exec_request` (pipe) row is configurable
/// at all in this configuration: `false` means the row declines —
/// at all in this configuration: `false` means the row declines;
/// the unset-declines contract, checked at request time by the
/// wave-2 dispatch, never here.
pub fn exec_wrapper_configured(&self) -> bool {
@@ -524,7 +524,7 @@ impl ServeConfig {
/// Whether the `-R` listen rows' registration seam is configured
/// in this configuration ([`TunnelListenSeam`] present; the
/// `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.
#[cfg(feature = "tunnel")]
pub fn tunnel_listen_seam_configured(&self) -> bool {
@@ -534,7 +534,7 @@ impl ServeConfig {
/// Whether the session rows' family seam is configured in this
/// configuration ([`SessionSeam`] present; the `session` feature
/// 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.
#[cfg(feature = "session")]
pub fn session_seam_configured(&self) -> bool {
@@ -543,8 +543,8 @@ impl ServeConfig {
/// Whether the git row's family seam is configured in this
/// configuration ([`GitSeam`] present; the `git` feature gate
/// applies at the dispatch on top): `false` means the row declines
/// — the unset-declines contract, checked at request time by the
/// applies at the dispatch on top): `false` means the row declines;
/// the unset-declines contract, checked at request time by the
/// wave-3 mappings, never here.
#[cfg(feature = "git")]
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
/// 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`
/// surface).
///
/// 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
/// default is fail-closed ([crate::host_key::RejectAll]).
///
/// **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 carried everything `client.md` §The dial surface names
/// 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;
/// the username is not identity and carries no routing semantics,
/// 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
/// is the remote end's to refuse.
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
/// fleet box). The same address labels the host-key policy's
/// presented-key decisions (host = the parsed host, port = the
/// parsed port).
pub address: String,
/// 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
/// crate's event vocabulary carries no username). See the type
/// docs.
pub username: String,
/// 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.
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.
/// The crate is policy-blind; the built-in default policy
/// ([crate::host_key::RejectAll]) connects to nothing, by design.
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
/// knobs ride here (see [`Limits`]; the keepalive 15 s / 3
/// default posture is the client direction's).
@@ -634,7 +634,7 @@ pub struct DialConfig {
impl DialConfig {
/// Builds a dial-config from the address, the user it
/// 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
/// 15 s / 3 posture riding inside).
pub fn new(
@@ -1033,7 +1033,7 @@ mod tests {
}
// 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
// feature isolation: no git build ever touches alktty types,
// no session build ever touches alkgit types).
@@ -1131,7 +1131,7 @@ mod tests {
/// - *Contract stamp:* the `w3-tunnel-listen` seam shape (the
/// 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,
/// and Debug-shapes without registry internals.
#[cfg(feature = "tunnel")]
+51 -51
View File
@@ -1,75 +1,75 @@
//! 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
//! ([`crate::handler`]).
//!
//! **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:
//! a leaked channel alloc is a capability leak). The parse is total:
//! it routes every input to exactly one of [`ExecRoute`]'s outcomes.
//! 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
//! assumes a sized input. Invalid UTF-8 parses to the pipe row
//! (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)
//!
//! 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
//! verified grammar table):
//!
//! - **`git-{upload,receive}-pack '<path>'`** → [`ExecRoute::Git`]:
//! 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
//! 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
//! `.git`; the alkgit registry grammar (`validate_repo_id`) excludes
//! `.`, so the door normalizes the idiom rather than asking
//! registries for dotted keys; an empty remainder keeps `.git` and
//! fails validation — never silently emptied). The normalized path
//! is *not yet validated* — the git row (feature-gated) validates it
//! fails validation, never silently emptied). The normalized path
//! is *not yet validated*: the git row (feature-gated) validates it
//! against `alkgit::validate_repo_id` (1–2 segments, charset, ≤ 100
//! bytes) and hands the id through; **alkssh never resolves** (the
//! no-existence-oracle posture lives in alkgit's collapsed generic
//! 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-send-pack` / `git-upload-archive`** →
//! [`ExecRoute::Declined`]([`ExecDecline::UnknownService`]): the
//! 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
//! (`w3-git-mapping`): they do **not** ride the pipe row; the decline
//! is build-independent (no feature ever serves them).
//! - **A serving service with no argument at all** (service-only) →
//! [`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).
//! - **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)
//! and unterminated quotes; the probe's routing verdicts are
//! preserved verbatim.
//!
//! ## The wire vocabulary (decision, recorded)
//!
//! SSH request replies carry no failure text, so — except for the open
//! level — the *reason* lives in the event vocabulary
//! SSH request replies carry no failure text, so, except for the open
//! level, the *reason* lives in the event vocabulary
//! ([`crate::observe`]), not on the wire. Decision: the grammar
//! declinables (`unknown-service`, `service-only`, and the git row's
//! `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),
//! distinguishable only in the events. The *serving-path* shape
//! (failure + close, the spawned task's) stays reserved for declined
//! rows that owned the channel: the seam-unconfigured git row and the
//! 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).
use russh::ChannelOpenFailure;
@@ -80,7 +80,7 @@ use russh::ChannelOpenFailure;
/// `w3-git-mapping` grammar with the declined-with-reason class).
#[derive(Debug, Clone, PartialEq, Eq)]
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
/// trailing `.git`); validation (`alkgit::validate_repo_id`) is
/// the git row's gate (the `crate::git_map` module).
@@ -88,35 +88,35 @@ pub enum ExecRoute {
service: GitServiceRoute,
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
/// builds decline here).
Pipe,
/// 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).
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
/// them verbatim; this type keeps the parse core feature-free).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GitServiceRoute {
/// `git-upload-pack` — fetch.
/// `git-upload-pack`: fetch.
UploadPack,
/// `git-receive-pack` — push.
/// `git-receive-pack`: push.
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 validation gate; that one needs `alkgit`'s grammar, this type
/// is the parse core's).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExecDecline {
/// 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).
UnknownService { service: &'static str },
/// A serving service name with no repo argument at all (POC-3
@@ -126,7 +126,7 @@ pub enum ExecDecline {
impl ExecDecline {
/// 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).
pub(crate) fn reason(self) -> &'static str {
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
/// the exact shape rules).
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 →
/// 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
/// verified shape). The client row reuses — never forks — the
/// grammar: it hands the constructed command back through
/// verified shape). The client row reuses the grammar, never forks
/// it: it hands the constructed command back through
/// [`parse_exec`] before any wire op, so the shared verdicts (the
/// per-length cap, the decline classes) ride both arms
/// (`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
/// 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
/// 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
/// to decline as `invalid-repo-id`, never silently emptied).
fn normalize_repo_path(argument: &str) -> Option<String> {
@@ -222,10 +222,10 @@ fn normalize_repo_path(argument: &str) -> Option<String> {
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):
/// `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
/// share this class honestly: without the feature there is no
/// 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
/// (`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
/// 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
/// `ConnectFailed` (the channel never establishes, so the client
/// observes a channel-scoped refusal, no hang). The same code covers
/// 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
/// cannot distinguish these from a dead target, and the event
/// 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
/// 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
/// at the open (never queued, never half-open). OpenSSH's own
/// `MaxSessions` ceiling answers the same code, so vanilla clients
@@ -281,8 +281,8 @@ mod tests {
git(command).1
}
/// - *Contract stamp:* ADR-006 decision 2 (the exec-only git parse
/// — the vanilla wire shape `git-upload-pack '/owner/demo'`).
/// - *Contract stamp:* ADR-006 decision 2 (the exec-only git parse:
/// the vanilla wire shape `git-upload-pack '/owner/demo'`).
#[test]
fn vanilla_quoted_upload_pack_parse_is_git() {
let (service, repo) = git("git-upload-pack '/owner/demo'");
@@ -316,16 +316,16 @@ mod tests {
assert_eq!(
git_repo("git-upload-pack '/r.git.git'"),
"r.git",
"one suffix only — the remainder fails validation honestly"
"one suffix only; the remainder fails validation honestly"
);
assert_eq!(
git_repo("git-upload-pack '/.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,
/// not the pipe row).
#[test]
@@ -341,7 +341,7 @@ mod tests {
}
/// - *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).
#[test]
fn unknown_services_parse_to_the_decline() {
@@ -360,7 +360,7 @@ mod tests {
}
/// - *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).
#[test]
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
/// slash is still path-shape (the POC's loose quote trim); it rides
/// 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
/// 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).
#[test]
fn empty_ids_parse_git_and_the_row_downstream_declines() {
@@ -399,7 +399,7 @@ mod tests {
}
/// - *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
/// `invalid-repo-id`, not at the parse).
#[test]
@@ -418,7 +418,7 @@ mod tests {
/// - *Contract stamp:* `client.md` §op rows (the same exec
/// 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
/// built from, for both services and every grammar shape.
#[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
/// (the type is the gate); the wire shape is fully
/// 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,
//! never to collapse).
//!
//! 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.
//! - **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.
//! - **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
//! config-error type exists unless a need is found.
use thiserror::Error;
/// 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
/// 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
/// re-export of it. Variants are deliberately closed: no catch-all,
/// no `Other`, no `Unknown`.
@@ -28,14 +28,14 @@ pub enum KeySourceError {
/// The vault was locked when the key was requested.
///
/// 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
/// host-key path the load feeds server construction
/// (decrypt-at-construction), so no server is constructed
/// (POC-2 §5). Live sessions are unaffected: the decoded
/// `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.
#[error("vault is locked; no key material available until unlocked")]
Locked,
@@ -43,7 +43,7 @@ pub enum KeySourceError {
/// No blob exists at the requested key location.
///
/// 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
/// variant on purpose: an absent key is a configuration/
/// 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")]
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):
/// 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
/// honest content here.
///
@@ -71,31 +71,31 @@ pub enum KeySourceError {
/// The record's key version is one the vault cannot resolve.
///
/// 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
/// is the other class) is refused at derivation-path resolution
/// (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.
///
/// Fail-closed consequence: no dial; on the host-key path, no
/// 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.
#[error("vault cannot resolve the record's version: {0}")]
UnknownKeyVersion(String),
/// 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.
///
/// ADR-005 semantics: the vault decrypted *something*
/// successfully and the plaintext is not an OpenSSH
/// 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
/// 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
/// path interpolates the raw header), so the payload is
/// crate-owned fixed text; the RC-pinned `ssh-key` error type
@@ -107,24 +107,24 @@ pub enum KeySourceError {
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:
///
/// - **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
/// (the [`KeySourceError`] variant is preserved verbatim — locked /
/// (the [`KeySourceError`] variant is preserved verbatim: locked /
/// absent / decryption / unknown-version / decode each stays itself)
/// 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).
/// **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
/// 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
/// reserved `From` requirement of the per-connection
/// [`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
/// 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.
#[derive(Debug, Error)]
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
/// original refusal class, preserved un-collapsed.
#[error("ssh server construction refused: {0}")]
@@ -140,14 +140,14 @@ pub enum AdapterError {
/// The `-R` listen leg's door-side openable registration refused
/// 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.
/// The string carries the registration failure detail.
///
/// This stays the construction-time class: the wire-time sibling
/// (`w4-router-collision`, the `ListenRouter::register`
/// 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
/// the request surface) would kill the whole russh session, and
/// 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
/// counterpart of [`AdapterError`]) — deliberately closed, every
/// counterpart of [`AdapterError`]), deliberately closed, every
/// variant a no-connection outcome (ADR-005 decision 4: failures are
/// 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
/// / 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
/// 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
/// validation, the family contract); a malformed target is a
/// refusal before any connection.
/// - **HostKeyRejected** — the injected host-key policy
/// - **HostKeyRejected**: the injected host-key policy
/// ([`crate::HostKeyPolicy`]) declined the presented server key at
/// `check_server_key` (a `false` verdict is the decline
/// disposition; russh surfaces it as its `UnknownKey` wire error,
/// 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
/// 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
/// 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.
#[derive(Debug, Error)]
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
/// un-collapsed (ADR-005 decision 4).
#[error("dial key source refused: {0}")]
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.
#[error("dial target is not a usable host:port address: {0}")]
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
/// crate default [`RejectAll`](crate::RejectAll) policy makes
/// every dial refuse, by design).
#[error("host-key policy declined the presented server key")]
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
/// fallback methods, never another key).
#[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:
/// `UnknownKey` is the wire shape of a host-key policy decline
/// (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.
fn from(error: russh::Error) -> Self {
match error {
@@ -235,22 +235,22 @@ impl From<russh::Error> for DialError {
}
/// 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
/// (`client.md` §op rows: the family surface carries no vanilla types).
/// The mapping is one-to-one with russh's
/// [`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.
#[derive(Debug, Clone, Error, PartialEq, Eq)]
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
/// declines answer with this family's server-direction mirror).
#[error("the server refused the open (administratively 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).
#[error("the server rejected the open (connect failed)")]
ConnectFailed,
@@ -263,7 +263,7 @@ pub enum OpenDeclineReason {
#[error("the server rejected the open (resource shortage)")]
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
/// for an unmappable one).
#[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
/// (`RemoteSession` / `TunnelChannel` / `GitPackSession` — the
/// (`RemoteSession` / `TunnelChannel` / `GitPackSession`, the
/// 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
/// never a no-connection outcome — the connection stays usable
/// never a no-connection outcome: the connection stays usable
/// ([`OpError::OpenDeclined`] / [`OpError::RequestRefused`] and
/// [`OpError::ChannelEnded`] are channel/op-scoped, mirroring the
/// server direction's decline shapes: channel-scoped, auth intact,
@@ -299,7 +299,7 @@ impl From<russh::ChannelOpenFailure> for OpenDeclineReason {
///
#[derive(Debug, Error)]
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
/// awaiting call, never a `wait()` match). The family-mirrored
/// reason rides; the connection stays usable (row-pinned).
@@ -307,7 +307,7 @@ pub enum OpError {
OpenDeclined(#[from] OpenDeclineReason),
/// 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
/// channel queue, not as `Err` from the request call). The
/// channel is closed behind the refusal (the row owned it).
@@ -315,26 +315,26 @@ pub enum OpError {
RequestRefused,
/// 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.
#[error("the channel ended before the op's request reply arrived")]
ChannelEnded,
/// 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
/// resources; the server direction's pre-parse cap mirrored at
/// the client's command-construction gate).
#[error("the op's command exceeds the configured exec-command length cap")]
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
/// `repo_id_validates` verdict), answered before any wire op.
#[error("the repository id is outside the repo-id grammar")]
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:
/// universal compile, clean off-Unix decline **before any wire
/// 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
/// registered** (ADR-003 §4's client-side gate surfaced to the
/// *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
/// exact class: the unset-declines convention mirrored (the
/// acceptor slots are assembly-mutable, install-first is the
@@ -353,27 +353,27 @@ pub enum OpError {
NoAcceptor,
/// 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
/// the raw wire strings). Channel-scoped honesty: the connection
/// 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.
#[error("the peer refused the reverse-forward request")]
ListenRefused,
/// The op's reverse-listen admission hit the connection's
/// 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
/// `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.
#[error("the connection's reverse-listen count is at its ceiling")]
ListenCapExceeded,
/// The sftp route is the OQ-13 decline placeholder (the feature
/// 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.
#[error("the sftp route is not served (the OQ-13 decline placeholder)")]
SftpNotServed,
@@ -389,7 +389,7 @@ impl From<russh::Error> for OpError {
/// `russh::Error::ChannelOpenFailure` and maps to
/// [`OpError::OpenDeclined`] (family-mirrored
/// [`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
/// [`OpError::ListenRefused`]; every other russh error stays the
/// transparent [`OpError::Wire`].
@@ -510,7 +510,7 @@ mod tests {
}
/// 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
/// class and leaves everything else `Wire`.
///
@@ -602,7 +602,7 @@ mod tests {
/// - *Contract stamp:* `client.md` §op rows (no vanilla-concept
/// 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.
#[test]
fn open_decline_mirror_preserves_every_class() {
@@ -653,7 +653,7 @@ mod tests {
}
/// - *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
/// transparent wire class; the typed-open mapping unchanged.
#[test]
@@ -662,7 +662,7 @@ mod tests {
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
/// `russh::Error::ChannelOpenFailure(..)`; the op rows map it
/// 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
//! directions* (`w4-op-rows` extracted it out of `tty_map.rs`, whose
//! server-side session rows were its first and only consumer).
@@ -12,27 +12,27 @@
//! exactly one SSH exit report shape, **never widened**.
//! - **The client direction** (`w4-op-rows`): the SSH exit report →
//! 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
//! 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).
//!
//! No widening in either arm: the chunk is not enriched, the wire
//! 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)
//!
//! 1. `code ≥ 0` → `exit-status(N)`.
//! 2. negative `−N`, N ≥ 2 → `exit-signal(name)` via the crate-local
//! **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
//! alktty's `signal_from_name` inverse, which is a forward
//! 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.
//! 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
//! on the pipe path, accepted symmetrically per the mapping:
//! 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
//! reaps a signal-terminated Unix child as a plain code (the
//! 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
//! sees the *pipe* path's negative encoding only. Faithful
//! `exit-signal` there needs the alktty backend to conform to its
//! own ADR's negative-code convention; alkssh's contract adapts
//! 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
//! producers are `−1` and `−signum` only).
//!
@@ -56,26 +56,26 @@
//! 1. `exit-status(N)` → the family code `N` (direct identity).
//! 2. `exit-signal(name)` → the family code `−N` via the same
//! [`SIGNAL_NAMES`] table read backwards
//! ([`signal_code_from_name`]) — the mapping never invents a
//! ([`signal_code_from_name`]); the mapping never invents a
//! signum.
//! 3. A name with no table entry → `−1` (the sentinel; the same
//! 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).
//! 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",
//! mapping.md): the caller observes the session's end, never a
//! 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(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg(feature = "session")]
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).
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.
Signal(&'static str),
}
@@ -83,11 +83,11 @@ pub(crate) enum ExitReport {
/// The crate-local Unix signum→name table (server arm rule 2 / client
/// arm rule 2): the 1–31 conventional Unix signals, as the exit
/// 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.
/// Deliberately **not** derived from alktty's `signal_from_name` (a
/// 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).
pub(crate) const SIGNAL_NAMES: &[(u32, &str)] = &[
(1, "HUP"),
@@ -124,7 +124,7 @@ pub(crate) const SIGNAL_NAMES: &[(u32, &str)] = &[
];
/// The derivation contract's whole decision procedure (the §module
/// docs' rules 1–6 — normative).
/// docs' rules 1–6; normative).
#[cfg(feature = "session")]
pub(crate) fn derive_exit(code: i32) -> ExitReport {
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
/// `exit-signal` sender's name → the family exit-code convention's
/// 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).
pub(crate) fn signal_code_from_name(name: &str) -> i32 {
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
/// `exit_signal_request` parameter). russh's known set rides its
/// 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")]
pub(crate) fn sig_from_name(name: &str) -> russh::Sig {
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
/// 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
/// arm's `exit-signal` consumption reads through this before
/// [`signal_code_from_name`]).
@@ -207,7 +207,7 @@ mod tests {
use russh::Sig;
/// - *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).
#[test]
fn non_negative_codes_map_to_exit_status() {
@@ -218,8 +218,8 @@ mod tests {
}
/// - *Contract stamp:* mapping.md §Exit chunk rule 2 (negative
/// `−N`, N ≥ 2 → `exit-signal(name)` via the table — the POC-3
/// §D.2 live evidence: `−15` → `exit-signal("TERM")`).
/// `−N`, N ≥ 2 → `exit-signal(name)` via the table; the POC-3
/// §D.2 live evidence is `−15` → `exit-signal("TERM")`).
#[test]
fn negative_signals_map_through_the_table() {
for (signum, name) in SIGNAL_NAMES.iter().skip(1) {
@@ -230,7 +230,7 @@ mod tests {
}
/// - *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).
#[test]
fn the_sentinel_is_not_a_signal() {
@@ -238,7 +238,7 @@ mod tests {
}
/// - *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]
fn unmapped_negatives_report_255() {
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
/// uppercase (the vocabulary the derivation table promises).
#[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
/// rows too).
#[cfg(test)]
@@ -290,7 +290,7 @@ mod tests {
mod client_arm_rows {
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:
/// mapped names ride `−signum`, unmapped names ride the `−1`
/// 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
//! registrations store it as a string. The string format
//! (`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
//! computation) must compare equal to what this crate computes.
//!
@@ -17,13 +17,13 @@
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).
///
/// The returned string is the OpenSSH fingerprint format: `SHA256:`
/// followed by the unpadded base64 of the SHA-256 digest over the
/// 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
/// assembly stores is this string; no other formatting path exists.
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
//! dispatch's [`ExecRoute::Git`](crate::dispatch::ExecRoute) route
//! dispatches to the published alkgit seam,
@@ -10,33 +10,33 @@
//!
//! ## 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
//! 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
//! `serve_duplex_resolved`. **alkssh never resolves**: the
//! resolution, the authorize policy and the collapsed generic
//! denial (unknown ≡ unauthorized, one wire shape, no existence
//! 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`]).
//!
//! - **Wire shapes per decline class** (the open/reply vocabulary):
//! - *Request-level* declinables (`unknown-service`, `service-only`
//! — the parse core's; `invalid-repo-id` — this module's gate):
//! - *Request-level* declinables (`unknown-service` and `service-only`
//! from the parse core; `invalid-repo-id` from this module's gate):
//! `channel_failure`, channel left usable. The reply vocabulary is
//! shared with parse-then-fail; the reason lives in the events
//! only.
//! - *Serving-path* declines (the row owned the channel):
//! 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.
//! - *The seam's own denials* are not alkssh declines: an unknown or
//! ungranted repo flows to `serve_duplex_resolved`, whose
//! collapsed generic denial writes the `ERR access denied: <repo>`
//! 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
//! `tests/git_mapping.rs`.
//!
@@ -45,7 +45,7 @@
//! built from the channel's **split halves**, never
//! `Channel::into_stream()`. `into_stream` wraps the whole channel
//! 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
//! `exit_status_request` for a queue position on the same bounded
//! `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.
//! The split-halves shape removes the competitor instead of racing
//! 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
//! repinning the private `ChannelRx`'s observation semantics
//! 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
//! messages the wire, so the report's send is the sole
//! 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
//! 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):
//! [`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
//! `AccessDenied` is the POC's wire evidence (the russh client
//! observed `ExitStatus{1}` after the denial band); `Io`/`Protocol`
@@ -77,32 +77,32 @@
//! machinery-misuse variants (`ResetRequired`/`WriterTaken`/
//! `RawTailUnavailable`) never arise from the serving entry
//! (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.
//! The status is sent **before** close, from the spawned serving
//! task via the cloned `Session::handle()`
//! ([`crate::serve`]'s mechanics; `mapping.md`'s exit_status row).
//!
//! - **Identity ride** (`w2-auth-bridge`'s parked item, discharged
//! here): the resolved identity — the bridge's
//! `auth_succeeded`-state ride-along in handler state — is cloned
//! here): the resolved identity (the bridge's
//! `auth_succeeded`-state ride-along in handler state) is cloned
//! into the serving task and passed on
//! `serve_duplex_resolved`'s identity param, so the visible-surface
//! invariant is observable through the git row: alkgit's
//! `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
//! 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
//! 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
//! duplex.
//!
//! 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
//! substrate already mapped its own error onto the wire (bands, the
//! denial), and the client's observation is the exit-status above.
@@ -123,7 +123,7 @@ use crate::dispatch::GitServiceRoute;
use crate::observe;
/// 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
/// decomposed, joined, or normalized into paths beyond
/// [`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 decision). russh 0.65 keeps its `ChannelRx`/
/// `ChannelTx` types private, so both halves are built from public
/// API: the reader is a [`ChannelHalveReader`] (an owned close-free
/// 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
/// `ChannelTx` `Drop` is a writer-notification only). Nothing 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`
/// shape's reader; identical observable behavior without the
/// `ChannelCloseOnDrop` wrapper): `Data` fills the buffer (partial
/// reads buffered), `ExtendedData` with no sideband and the other
/// non-data messages are swallowed and re-read (the `_` arm),
/// `Eof` ends the stream, and the peer's `Close` leaves the reader
/// to the channel's sender-death EOF (`wait()` → `None` — the
/// channel's internal sender drops at the loop's close processing).
/// to the channel's sender-death EOF (`wait()` → `None`, the
/// channel's internal sender dropping at the loop's close processing).
///
/// The `wait()` access is future-owned: each read round-trips the
/// half through a boxed [`Future`] that parks it inside russh's
/// `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
/// drop state; no `Drop` path ever messages the wire.
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
/// [`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
/// 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
/// issued, so the status-before-close order holds by construction
/// under any schedule. The channel was taken from the handler's store
/// by the dispatch (the store-and-take handoff); the spawned task owns
/// 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).
pub(crate) async fn serve_git_row(
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
//! core (`w2-dispatch-decline-core`; `w2-auth-bridge` filled the auth
//! hooks; `w3-git-mapping` replaced the skeleton probe with the full
@@ -9,30 +9,30 @@
//!
//! russh performs all cryptographic work: [`Handler::auth_publickey`]
//! 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
//! one fingerprint path ([`crate::fingerprint::fingerprint`]) → the
//! injected `IdentityProvider::resolve_from_fingerprint` → `Some`
//! 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
//! fail-closed — the resolved-identity-`None` path never creates a
//! fail-closed; the resolved-identity-`None` path never creates a
//! 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`]
//! 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
//! 3). The non-ed25519 algorithm decline is explicit-over-inherited too
//! (ADR-002 decision 4). [`Handler::auth_publickey_offered`]
//! accepts-and-delegates explicitly (the probe hook consumes no attempt
//! — POC-1 verified; the delegation keeps `max_auth_attempts` counting
//! accepts-and-delegates explicitly (the probe hook consumes no attempt,
//! POC-1 verified; the delegation keeps `max_auth_attempts` counting
//! honest).
//!
//! 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.
//! Auth declines are *dispositions* (`Auth::Reject`), not errors — the
//! Auth declines are *dispositions* (`Auth::Reject`), not errors; the
//! error model stays key-source-only.
//!
//! ## The parse-only dispatch + the fail-closed decline core
@@ -41,23 +41,23 @@
//! exec-parse skeleton probe live in [`crate::dispatch`].) Every
//! `channel_open_*` hook gates per the feature graph: `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
//! the `tunnel` feature; **`x11` always declines** — never
//! the `tunnel` feature; **`x11` always declines**, never
//! feature-gated (ADR-003 §5). Unfeatured and never-feature rows
//! 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`
//! featured rows are the route-1 bridge (`w3-tunnel-direct`,
//! [`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),
//! 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
//! ([`crate::dispatch::connect_failed_open_decline`], the wave-2
//! seam stub's reason surviving as the real decline).
//! An **inbound** `forwarded-tcpip` open — arriving *from* the vanilla
//! client — declines with the same explicit `ConnectFailed` as the
//! An **inbound** `forwarded-tcpip` open, arriving *from* the vanilla
//! client, declines with the same explicit `ConnectFailed` as the
//! unvanilla-arrival class (`w3-tunnel-listen`: the real `-R` data
//! leg is the door's own server-initiated opens, driven by the
//! [`crate::tunnel_map`] arrival pump; a vanilla client never
@@ -65,47 +65,47 @@
//! The bare
//! `auth-agent@openssh.com` channel *type*
//! (`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-
//! feature contract needs, but it is an inherited posture: a pin bump
//! that changed it would surface here as an accepted-then-dropped
//! channel (third-party-upstream note; file upstream 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
//! decline, anything else to the session pipe row); `pty_request` /
//! `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.
//! **Decline first, alloc never**: every decline here precedes any
//! 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.
//!
//! The store-and-take channel handoff (`w3-git-mapping`)
//!
//! A confirmed `session` open **stores** its [`Channel`] in
//! 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`
//! **takes** it (`channels.remove(&id)` → `into_stream()` in the
//! spawned serving task) and the session's packet loop keeps owning
//! the store: `channel_close` removes whatever it held (a channel
//! 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
//! (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 pty/shell/pipe rows replaced their wave-3 skeleton stubs with
//! the alktty translation bridge ([`crate::tty_map`]): the pty row
//! 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
//! 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 →
//! the shell row declines, `exec_wrapper_argv` unset → the pipe row
//! declines, `session_seam` unset → both rows and the pty row
@@ -118,15 +118,15 @@
//! ## The control rows' per-channel surface (`w3-tty-session`)
//!
//! `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
//! ([`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
//! but not yet serving updates the stored params (the final size
//! wins); signal names are accepted verbatim (the backend owns the
//! 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
//! surface (firing the cancel: the alktty kill-on-drop contract is
//! 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)
//!
//! 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
//! proved by construction with receive-pack). Every serving path is
//! therefore spawned through [`crate::serve::spawn_serving_path`] and
//! the handler returns fast: the serving rows (the git row's alkgit
//! dispatch, the session rows' config-driven declines and
//! [`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
//! (failure then close), riding the spawn. Exit/status reporting from
//! 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
//! 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`.
//!
//! The open-level declines (unfeatured/never-feature rows, the
//! 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
//! the direct rows' accept past the fabric open: the accept and the
//! pump ride the spawned task, per the same constraint. The `-R`
//! listen rows' family-ward dial is the one **inline await** on the
//! loop (`w3-tunnel-listen`'s recorded decision: the global request's
//! 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
//! 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
//! answer rides the fabric leg's connections, not this session's
//! 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
//! does not until alkfs lands); git service names carry only a name,
//! never a repository id, so git can never dispatch as a subsystem
//! (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
//! (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
//! seam configured (the family-ward `TunnelListener::open` dial, the
//! arrivals' arrival pump through the shared router); every decline
@@ -186,7 +186,7 @@
//! Two decline classes, chosen for vanilla-client observability (POC-1
//! §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'
//! identity-less guard and beyond-cap refusals → administratively
//! prohibited; the direct rows' cannot-bridge classes (the fabric
@@ -196,54 +196,54 @@
//! connect failed). These
//! happen before the channel exists, so the SSH open-failure code is
//! 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
//! decline's "reason" is the routing decision itself (documented
//! above, in [`crate::dispatch`]'s grammar, and per feature build in
//! `mapping.md`'s decline matrix). A *capability* decline answers
//! 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` /
//! `invalid-repo-id`, the parsed reply shapes, `w3-git-mapping`);
//! a *serving-path* decline (a routable row that owns the request's
//! whole outcome: the seam-unconfigured git row, the session rows'
//! unset-field and unset-seam declines — `w3-tty-session`'s OQ-08
//! shapes — and the identity-less guard) answers failure and closes
//! the channel — the vanilla-clean shape for a row whose serving
//! unset-field and unset-seam declines, `w3-tty-session`'s OQ-08
//! shapes, and the identity-less guard) answers failure and closes
//! the channel, the vanilla-clean shape for a row whose serving
//! path cannot exist. The serving-path decline rides the spawned
//! serving task (ADR-006 decision 4, §The spawn-and-return
//! structural constraint above); the wire shape is unchanged. The
//! `channel-not-held` guard is the inline request-level shape (the
//! channel that could not be held stays usable). The real serve's
//! own outcomes are not declines: the substrate maps them (denial
//! band before any protocol byte; exit-status then close — the
//! `exit_code` mapping decision recorded in [`crate::git_map`];
//! band before any protocol byte; exit-status then close (the
//! `exit_code` mapping decision recorded in [`crate::git_map`]);
//! the tty bridge's negotiation-error and exit-chunk decisions in
//! [`crate::tty_map`]).
//!
//! ## The event vocabulary on every decline (`w3-decline-tracing`)
//!
//! 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,
//! reason) rides the tracing event, not just the routing decision
//! 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)
//! hangs off the spawned stub body and is what the wave-3 mappings'
//! real serving paths inherit.
//!
//! ## Bounded resources at the handler surface (`w2-bounded-resources`)
//!
//! (AGENTS.md convention 9; `overview.md` §Bounded-resources policy —
//! the "concurrent channels per session — ours" row.) The in-crate
//! (AGENTS.md convention 9; `overview.md` §Bounded-resources policy,
//! the "concurrent channels per session: ours" row.) The in-crate
//! concurrent-channel cap ([`crate::channel_cap::ChannelCap`]) is
//! enforced at every `channel_open_*` hook: an accept path beyond
//! `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
//! 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
//! shape: ADR-006 decision 1 rejects the open, so no `channel_close`
//! can ever fire for it; the release mechanism is the shared
@@ -251,14 +251,14 @@
//! its two budget surfaces (`w3-tunnel-listen`, recorded in
//! `channel_cap.rs`): the arrivals admit into the owning route's
//! 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
//! listener/channels mix stays unconstrained; both stay bounded). The
//! order of the
//! gates: the feature gate first (an unfeatured build declines
//! everything before the cap matters), then the pure-decline classes
//! (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
//! admitted count is held across the fabric open and released exactly
//! once on either path. The `max_exec_command_len` cap is
@@ -272,7 +272,7 @@
//! The handler type is deliberately constructible outside this module
//! (re-exported `#[doc(hidden)]` from the crate root) so the wave-1
//! 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
//! the bridge core is its own method ([`SessionHandler::resolve_bridge`])
//! the tests call with ordinary key material; the wire-level rows
@@ -308,36 +308,36 @@ use crate::tunnel_map;
use alktty::negotiation::NegotiateRequest;
/// 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`.
///
/// 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
/// path, bounded resources, AGENTS.md convention 9), and the resolved
/// 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 {
serve: Arc<ServeConfig>,
/// The in-crate concurrent-channel cap (`w2-bounded-resources`),
/// shared as an `Arc` since `w3-tunnel-direct`: the route-1 bridge's
/// 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
/// have two sites since `w3-tunnel-listen` — the packet loop's
/// open hooks and the `-R` arrival tasks — and releases three; the
/// have two sites since `w3-tunnel-listen`: the packet loop's
/// open hooks and the `-R` arrival tasks, and releases three; the
/// mutex makes them sound (the reasoning, recorded in
/// `channel_cap.rs`).
channel_cap: Arc<ChannelCap>,
/// The `-R` listen leg's per-session bookkeeping
/// (`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
/// teardown remove, single-writer/no-lock like the `channels`
/// store) and the listen count's dedicated
/// `max_concurrent_channels`-ceiling counter (a listen is at least
/// 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).
#[cfg(feature = "tunnel")]
listeners: HashMap<u32, tunnel_map::PendingListen>,
@@ -349,7 +349,7 @@ pub struct SessionHandler {
/// the close releases them. Written only by the packet loop's
/// hooks (`channel_open_session` inserts; the git row's and the
/// 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>>,
/// The session rows' negotiated pty params (`w3-tty-session`):
/// `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
/// cancels its alktty session (the kill-on-drop cleanup) instead
/// 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
/// alkgit seam's own `session_wall_clock` (the alkgit limits the
/// assembly configured). The `tunnel` build adds the `-R`
/// leg's teardown (`w3-tunnel-listen`): the session's pending
/// 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
/// fabric-side reap). Any in-flight arrival already past the
/// route lookup fails its dead-handle open and releases its
/// admission — the never-established path.
/// admission, the never-established path.
fn drop(&mut self) {
#[cfg(feature = "session")]
self.teardown.fire();
@@ -401,7 +401,7 @@ impl Drop for 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
/// `alkcall::core::auth::IdentityProvider`; the bridge only
/// resolves through it.
@@ -428,14 +428,14 @@ impl SessionHandler {
/// store-and-take handoff): the git row's and the session rows'
/// dispatches remove the handle the open stored, handing the
/// 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).
#[cfg(any(feature = "git", feature = "session"))]
fn take_stored_channel(&mut self, channel: ChannelId) -> Option<Channel<Msg>> {
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
/// fail-closed rejecting paths never leave a weakly-authorized
/// state behind). The wave-3 seam propagation consumes this.
@@ -447,15 +447,15 @@ impl SessionHandler {
/// cap (bounded resources, `w2-bounded-resources`): room under
/// `Limits::max_concurrent_channels` admits and counts the channel;
/// beyond the cap the open is refused fail-closed with a clean
/// wire reason ([`crate::dispatch::cap_exceeded_open_decline`]) —
/// never queued, never half-open. Called exactly at accept sites —
/// wire reason ([`crate::dispatch::cap_exceeded_open_decline`]),
/// never queued, never half-open. Called exactly at accept sites:
/// the packet loop owns every admit call (the single admit writer,
/// [`ChannelCap`]'s recorded reasoning); a pure-decline path
/// (unfeatured, never-feature, the tunnel rows' pre-admit classes)
/// never touches the count. Since `w3-tunnel-direct`, the tunnel
/// rows admit **at the hook and hold the count across the fabric
/// 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
/// closes ([`Self::channel_close`]). The release sites are disjoint
/// per admission by the wire shape.
@@ -467,16 +467,16 @@ impl SessionHandler {
/// The ADR-004 decision 1 bridge core, structured out of the
/// [`Handler::auth_publickey`] hook so the contract tests can drive
/// it directly: the hook's russh-owned argument shape
/// (`russh::server::VerifiedPublicKey`) is `#[non_exhaustive]` —
/// only russh constructs it — but this method takes the two facts
/// (`russh::server::VerifiedPublicKey`) is `#[non_exhaustive]`,
/// only russh constructs it, but this method takes the two facts
/// the hook receives (the verified key and the signature's
/// 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
/// explicit-over-inherited so it survives a pin bump), then the
/// 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
/// fail-closed reject disposition; `Some` carries the identity the
/// hook stores in handler state.
@@ -503,7 +503,7 @@ impl SessionHandler {
/// The `session` channel-open gate (ADR-003 feature graph):
/// 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.
fn serves_session_channels(&self) -> bool {
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
/// `Auth::Accept` keeps the `max_auth_attempts` counting honest;
/// the real disposition is [`Self::auth_publickey`]'s.
@@ -566,14 +566,14 @@ impl russh::server::Handler for SessionHandler {
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).
async fn auth_none(&mut self, _user: &str) -> Result<Auth, Self::Error> {
observe::decline_auth("auth_none", "method-unimplemented");
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
/// whole story.
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())
}
/// 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;
/// future support is new-ADR territory.
async fn auth_openssh_certificate(
@@ -604,16 +604,16 @@ impl russh::server::Handler for SessionHandler {
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
/// `channel_close` corresponds to exactly one previously-admitted
/// 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
/// single release point, saturating in [`ChannelCap::release`] on
/// top. The spawned serving paths never touch the count directly:
/// 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`].
async fn channel_close(
&mut self,
@@ -634,15 +634,15 @@ impl russh::server::Handler for SessionHandler {
/// The session-channel open gate (ADR-003 feature graph,
/// `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
/// — 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
/// ([`crate::dispatch::cap_exceeded_open_decline`]), never queued.
/// A confirmed open accepts the channel and counts it; the count
/// releases when the channel closes ([`Self::channel_close`]). The
/// 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(
&mut self,
channel: Channel<Msg>,
@@ -664,7 +664,7 @@ impl russh::server::Handler for SessionHandler {
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
/// cap never applies here: a count is consumed only at an accept
/// 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
/// dispatch (`w3-tunnel-direct`): unfeatured declines at the 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
/// pre-admission) and route to [`Self::dispatch_direct_open`]:
/// 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
/// 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
/// open succeeds (`ConnectFailed` on failure, the count released
/// 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
/// 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
/// server-initiated opens via [`crate::tunnel_map`]'s arrival
/// 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
/// 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
/// 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
/// it rides the same bridge with `SubstrateAddr::Path` +
/// `Substrate::Unix`.
@@ -821,9 +821,9 @@ impl russh::server::Handler for SessionHandler {
/// The exec-grammar dispatch (ADR-006 point 3 as amended; 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:
/// 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
/// honest request-level decline, anything else to the session
/// 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 →
/// `TerminalParamsWire` (ttymodes → the reserved `{}` JSON,
/// 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,
/// `seam-unconfigured`). Unfeatured builds decline the
/// 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).
async fn pty_request(
&mut self,
@@ -930,11 +930,11 @@ impl russh::server::Handler for SessionHandler {
/// The `shell_request` row (`session`): the serve-config shell
/// 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);
/// unset-declines first (OQ-08: `shell_argv` unset →
/// `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
/// outcome, status-before-close from the spawned task.
async fn shell_request(
@@ -998,13 +998,13 @@ impl russh::server::Handler for SessionHandler {
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
/// (`try_send`, never awaited on the packet loop); for a channel
/// 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
/// 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`].
async fn window_change_request(
&mut self,
@@ -1050,11 +1050,11 @@ impl russh::server::Handler for SessionHandler {
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;
/// alktty's control handle applies its own default kill for
/// 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
/// wire, and the bridge's bounded surface is the only path. An
/// 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):
/// `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
/// names carry only a name, never a repository id, so git can
/// 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
/// 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`]).
async fn env_request(
&mut self,
@@ -1132,7 +1132,7 @@ impl russh::server::Handler for SessionHandler {
}
/// 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
/// decline; russh replies the channel failure.
async fn agent_request(
@@ -1146,7 +1146,7 @@ impl russh::server::Handler for SessionHandler {
/// The `tcpip-forward` listen request (`-R` ingress, `w3-tunnel-
/// 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
/// (`port = 0` → the fabric's actually-bound number, the sshd
/// 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,
/// `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
/// carried it, the bound port), the route removed **before** any
/// teardown (a racing arrival is already the correlation-miss
/// class), the listener count released, the fabric channel's
/// 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`).
async fn cancel_tcpip_forward(
&mut self,
@@ -1206,7 +1206,7 @@ impl russh::server::Handler for SessionHandler {
}
/// 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
/// it declines before any fabric call; universal compile, no
/// 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
/// 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
/// anonymous listens.
/// 2. **The seam checks** — the fabric handle or the listen seam
/// (`listen_router` absent: an egress-only door — the seam unset or
/// 2. **The seam checks**: the fabric handle or the listen seam
/// (`listen_router` absent: an egress-only door, whether the seam unset or
/// the registration never ran) decline `seam-unconfigured` before
/// 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
/// 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
/// `cap-exceeded`.
///
/// 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 +
/// 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
/// bounded by `LISTEN_DIAL_TIMEOUT`, so the stall is bounded). The
/// reply's `bound` port writes the out-param (`port = 0` → 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
/// 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
/// 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
/// packet-loop-only map. `Ok(true)`'s answer is the only success
/// 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
/// the original request carried it + the bound port for the tcp
/// 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
/// variant. Order: the entry leaves the session map and the route
/// leaves the shared router before any family teardown spawns (a
/// racing arrival that has not yet cloned its route is already the
/// correlation-miss class — no post-cancel half-serve; one that
/// cloned just before completes independently — the recorded
/// correlation-miss class, so no post-cancel half-serve; one that
/// cloned just before completes independently (the recorded
/// 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
/// may await). `Ok(true)` iff a pending listen matched.
#[cfg(feature = "tunnel")]
@@ -1449,7 +1449,7 @@ fn cancel_listen_dispatch(
/// The request-level decline shape for an unfeatured/unimplemented
/// 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
/// context); this is the wire shape only.
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,
/// reused by `w3-tty-session`'s config-driven declines): fail the
/// 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
/// history (the decline IS the path it spawns). Decline-first-alloc-
/// 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
/// ([`crate::observe`]): started (the task's first action), the
/// 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
/// vocabulary).
#[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
/// 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,
/// event-shaped ([`crate::observe`]) with the caller's reason.
#[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`):
/// 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
/// ([`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
/// spawned alkgit dispatch ([`crate::git_map::serve_git_row`], the
/// resolved identity riding along). A routed exec answers
/// `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.
#[cfg(feature = "git")]
fn git_row_dispatch(
@@ -1569,12 +1569,12 @@ fn git_row_dispatch(
}
/// 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
/// shape, never a fallback), then the store-and-take handoff
/// (`channel-not-held` → the inline unvanilla decline, the git row's
/// 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
/// `channel_success` inline (the request routed; the exit chunk
/// reports the serving outcome later).
@@ -1637,7 +1637,7 @@ fn pipe_row_dispatch(
}
/// 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
/// routed request answers `channel_success` inline).
#[cfg(feature = "session")]
@@ -1674,24 +1674,24 @@ fn spawn_tty_row(
}
/// The direct tunnel rows' dispatch (`w3-tunnel-direct`): the
/// pre-admit decline classes first — **decline first, alloc never**
/// applies to the cap budget too, so every consumer is checked before
/// pre-admit decline classes first, because **decline first, alloc
/// 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 at the hook — the packet-loop's single
/// admit-writer site, no race window against the cap check — and held
/// the admission is taken at the hook, the packet-loop's single
/// admit-writer site, no race window against the cap check, and held
/// across the fabric open):
///
/// 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`])
/// — the bridge cannot be attempted at all.
/// 2. **The `no-identity` guard** — unreachable on the wire (russh
/// as the bridge cannot be attempted at all.
/// 2. **The `no-identity` guard**: unreachable on the wire (russh
/// authorizes before channels exist) and fail-closed: never
/// 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
/// [`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`.
///
/// 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
//! `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):
//!
//! 1. **Known beforehand** (trust anchors exist; family-built runners
//! mint their host key at assembly time): assembly-supplied pins
//! 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
//! public config, ADR-005 decision 6; never vault material).
//! 2. **Unknown beforehand** (vanilla `sshd` on rental fleets; no
//! anchor until first contact): prompt-and-record,
//! 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
//! its [`HostKeyPolicy`], never crate behavior.
//!
//! Crate rules:
//!
//! - 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
//! permissive policy is the assembly's to write when its fleet
//! model calls for one.
@@ -32,11 +32,11 @@
//! Result<bool, Self::Error>`): the bool *is* the trust decision,
//! and the handler's `Err` channel stays wire/IO-failure territory.
//! 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.
//! - The [`PresentedHostKey`]'s fingerprint string is the contract-
//! 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.
//!
//! This module is the seam and its default only: the russh
@@ -45,17 +45,17 @@
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
/// key that endpoint presented.
#[derive(Debug, Clone, PartialEq, Eq)]
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.
pub host: String,
/// The TCP port the dial targeted.
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
/// against assembly pins or recorded entries is exact string
/// equality by contract.
@@ -65,13 +65,13 @@ pub struct PresentedHostKey {
/// The trust-decision contract the dial surface calls (OQ-05): the
/// crate surfaces the presented host key (host, port, fingerprint) to
/// 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).
///
/// The handle shape is the family's injected-handle pattern
/// (`Arc<dyn HostKeyPolicy>`, the alkcall `IdentityProvider`
/// 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.
///
/// **Fail-closed posture**: when a policy cannot decide (its pin
@@ -81,19 +81,19 @@ pub struct PresentedHostKey {
#[async_trait]
pub trait HostKeyPolicy: Send + Sync + 'static {
/// 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`
/// `false` → `Error::UnknownKey`, a hard, clean decline; a
/// mismatched pin is a hard decline, never a soft failure).
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.
///
/// russh's own reject-all default, kept as the crate's built-in
/// 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
/// never let a dial through on the crate's authority. There is no
/// 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).
//!
//! The adapter takes *key sources*, not static keys (the alkhttp
@@ -7,26 +7,24 @@
//! implements them over an injected alkvault handle. The crate ships
//! the vault-backed implementations for the ADR-005 shape
//! ([`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
//! assembly's.
//!
//! Crate rules, per ADR-005:
//!
//! - **`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
//! anywhere in the crate; version semantics belong to the vault.
//! - **At-use lifecycle** (decision 2): `vault.decrypt(blob)` →
//! `Zeroizing<String>` wrap at the adapter seam (decision 3 — the
//! known-gap contract; the vault-side improvement is upstream, not
//! here) → one-time
//! the at-use chain's single-use read → one-time
//! [`decode_secret_key`](russh::keys::decode_secret_key) → the
//! decoded `PrivateKey` lives for the dial's lifetime. The vault is
//! needed only at use time: locking the vault mid-session keeps
//! live sessions streaming while new loads fail (POC-2 §2).
//! - **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
//! a [`KeySourceError::Decode`] refusal, not a fallback.
//! - **Failure modes are fail-closed with distinct variants**
@@ -37,7 +35,7 @@
//! - **Plaintext never persists** (decision 7): no disk spill on this
//! path, ever.
//! - 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 async_trait::async_trait;
@@ -46,7 +44,7 @@ use zeroize::Zeroizing;
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).
///
/// 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
/// established sessions streaming; POC-2 §2). Implementations must be
/// 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.
///
/// The handle shape is the family's injected-handle pattern
/// (`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
/// [`VaultServiceHandle`] into its source). The crate's ADR-005
/// implementation is [`VaultKeySource`]; an assembly may implement the
@@ -67,48 +65,48 @@ use crate::error::KeySourceError;
/// contract (OpenSSH-PEM, unencrypted).
#[async_trait]
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).
///
/// This is the only surface where the dial direction touches
/// secret material: the plaintext exists solely inside this
/// method and is dropped (zeroized from the adapter seam onward)
/// before the decoded key is returned.
/// method and is zeroized by the vault's `Zeroizing<String>` return
/// at its drop, before the decoded key is returned.
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).
///
/// 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
/// [`HostKeySource::load`] when it assembles the server, and a failure
/// means no server is constructed (POC-2 §5; locked vault at
/// bootstrap → [`KeySourceError::Locked`] → fail-closed, never a
/// 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
/// config, never vault material).
///
/// The set, not a single key: `Config::keys` accepts multiple
/// 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`].
#[async_trait]
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
/// source's registration order.
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.
///
/// The record is injected at construction, from the assembly's
/// registry (`None` = the assembly holds no record at this key's
/// 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`]
/// is decrypt-at-usage and a locked vault is a clean
/// [`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.
///
/// 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
/// registration gap), which the wave-2 config builder treats exactly
/// 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
/// `Zeroizing<String>` seam wrap, one `decode_secret_key`.
/// The ADR-005 at-use chain (decision 2): one vault decrypt, one
/// `decode_secret_key`.
///
/// The plaintext `String` leaves the vault as
/// [`KeySourceError`]-mapped failure or enters the wrap immediately —
/// its only unfenced window is the vault's own `String` return, the
/// known-gap contract of ADR-005 decision 3 (the upstream ask is
/// alkvault's; OQ-07). The decode is the single inner-format
/// conversion (decision 1); a plaintext that is not OpenSSH-PEM key
/// material is a [`KeySourceError::Decode`] refusal.
/// The plaintext leaves the vault as
/// [`KeySourceError`]-mapped failure or as alkvault 0.2's
/// `Zeroizing<String>` return (the pre-wrap unfenced window ADR-005
/// decision 3 recorded is closed upstream: the vault fences the
/// plaintext from its return onward, zeroized on drop, including the
/// vault's own UTF-8 failure path; OQ-07's upstream ask is resolved,
/// 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(
vault: &VaultServiceHandle,
blob: &EncryptedData,
) -> 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)
}
/// 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`]
/// (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
/// 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,
/// not a fork). Not documented API: the vault-facing surface is
/// exactly [`VaultKeySource`] / [`VaultHostKeySource`].
///
/// The `Zeroizing` posture rides the signature: the plaintext arrives
/// already wrapped at the adapter seam (decision 3), the decode reads
/// it once, and nothing is retained — `Ok` carries the decoded
/// The `Zeroizing` posture rides the signature: the plaintext
/// arrives already zeroized (alkvault 0.2's return type, decision
/// 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`]
/// class (never the plaintext).
///
/// The typed `Decode` payload is crate-owned fixed text, deliberately
/// *not* the underlying machinery's message (the `w5-fuzz-harness`
/// finding, 2026-10-30): russh's/`ssh-key`'s PPK parse path
/// (`PpkParseError::Header`) interpolates raw header bytes — the
/// plaintext's own material — into its Display, so a machinery message
/// (`PpkParseError::Header`) interpolates raw header bytes, the
/// plaintext's own material, into its Display, so a machinery message
/// riding the typed payload would echo decrypted-record material
/// exactly the no-persist posture (decision 7) forbids. The upstream
/// 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> {
decode_secret_key(plaintext, None).map_err(|_| {
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 \
material; the upstream echo ask is recorded)"
.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
/// 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
/// 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
/// class: wrong mnemonic, tampered ciphertext) is honest refuted
/// decryption → [`KeySourceError::Decryption`]. The remaining vault
/// variants cannot arise on the decrypt path (decryption never
/// touches mnemonic parsing, key derivation outside version-path
/// 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
/// with the vault's message intact rather than silently succeeding.
fn map_vault_error(error: VaultServiceError) -> KeySourceError {
@@ -272,14 +273,14 @@ mod tests {
use crate::error::KeySourceError;
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
/// `include_str!` (the `tests/common/mod.rs` double module is the
/// tests/-side owner; src-unit tests never own fixture copies).
/// Throwaway `ssh-keygen`-minted keys, never registered anywhere
/// real (`ALPHA` is the client-dial fixture; `BRAVO`/`CHARLIE`
/// 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.
const ALPHA_PEM: &str = include_str!("../tests/vectors/alpha.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
//! wire and bridging to/from alkcall channels (the `alk/ssh` ALPN).
//!
//! 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 →
//! alkgit, sftp → alkfs later (a declining placeholder until then), each
//! behind its cargo feature. Git over SSH is exec-only (ADR-006): a
//! subsystem request can never carry the repository, so it declines. 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
//! anything but declines.
//!
//! Identity is the family's, not SSH's: a verified key's fingerprint →
//! 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.
//!
//! 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 client direction — dial, op rows, the `-R` acceptor leg, and the
//! lifecycle primitives — are landed through wave 4; the validation
//! the client direction (dial, op rows, the `-R` acceptor leg, and the
//! lifecycle primitives) are landed through wave 4; the validation
//! suite and the release are wave 5). See also `docs/research/`,
//! `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
//! 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
//! (no features) has no capabilities**: it can serve nothing but clean
//! 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.
//! - `session` — pty/shell/exec rows → the `RemoteSession` chunk
//! - `session` maps the pty/shell/exec rows → the `RemoteSession` chunk
//! 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
//! 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`) →
//! 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
//! alkfs mapping lands; the feature exists so the decline stays
//! contract territory (tested in every matrix build).
//!
//! Advertisement is honest: an unfeatured channel type is never
//! 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).
//!
//! # 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
//! architecture spec, not defects awaiting a fix:
//!
@@ -64,29 +64,29 @@
//! per-channel window, the client's own flow control) and liveness
//! is covered by the always-set
//! [`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.)
//! - **The PTY path's exit-signal fidelity is limited by upstream**
//! (exit-currency rule 5): signal-terminated PTY sessions surface as
//! `exit-status(1)` because the backend's reap surface loses the
//! 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.
//! - **The port-0 mint is the only bound-port echo the wire carries**
//! (russh/sshd parity): a `tcpip_forward` success reply carries the
//! 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.
//! - **No reconnect/restore policy.** The client direction ships
//! lifecycle *primitives* (bound ports, cancellable handles, clean
//! [`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
//! this russh pin: only an explicit `close` sends the clean
//! disconnect.
//! - **No accept-all host-key policy exists in the crate.** The
//! 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
//! fail-closed policy posture: when a policy cannot decide, it must
//! decline.
@@ -96,10 +96,10 @@
//! The crate is a door, not a deployment: an assembly embeds it,
//! enables the features it wants, and injects the seams (identity,
//! 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.
//!
//! 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`] /
//! [`VaultHostKeySource`] shapes):
//!
@@ -151,7 +151,7 @@
//! ```
//!
//! 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):
//!
//! ```rust,no_run
@@ -182,7 +182,7 @@
//! # {
//! # 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(())
@@ -225,7 +225,7 @@ pub use client::dial;
pub use client::russh_client_config;
/// The `-R` leg's acceptor registry + the reverse-listen rows'
/// 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).
#[cfg(feature = "tunnel")]
pub use client_accept::{
@@ -235,14 +235,14 @@ pub use client_accept::{
#[doc(hidden)]
pub use client_handler::ClientHandler;
/// The client op rows' surfaces (`w4-op-rows`):
/// [`RemoteSession`] — the exec/pty rows'
/// [`RemoteSession`]; the exec/pty rows'
/// family chunk plane (under `session`),
/// [`TunnelChannel`] — the direct
/// [`TunnelChannel`]; the direct
/// rows' byte duplex (under `tunnel`),
/// [`GitPackSession`] — the upstream git
/// [`GitPackSession`]; the upstream git
/// row's pack duplex + consumed exit (under `git`). The op methods
/// 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.
#[cfg(feature = "git")]
pub use client_ops::GitPackSession;
@@ -258,7 +258,7 @@ pub use config::SessionSeam;
pub use config::TunnelListenSeam;
pub use config::{DialConfig, Limits, ServeConfig};
/// 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
/// documented API: the grammar is the dispatch seam's, not a public-API
/// promise.
@@ -267,7 +267,7 @@ pub use dispatch::{ExecDecline, ExecRoute, GitServiceRoute, parse_exec};
pub use error::{AdapterError, DialError, KeySourceError, OpError, OpenDeclineReason};
pub use fingerprint::fingerprint;
/// 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
/// documented API: the wire-facing surface is exactly `SshAdapter` +
/// 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::{HostKeySource, KeySource, VaultHostKeySource, VaultKeySource};
/// 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
/// real wire to pin the constraint as tests. Not documented API.
#[doc(hidden)]
+57 -57
View File
@@ -1,5 +1,5 @@
//! 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,
//! and the serving-path lifecycle skeleton ([`serving_path_started`] /
//! [`serving_path_ended`] / [`serving_path_failed`]) the wave-3
@@ -7,7 +7,7 @@
//!
//! ## 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`,
//! `local/pty.rs`): free `tracing::{debug, warn}` imports,
@@ -18,7 +18,7 @@
//! (`listen = …`, `error = %e`, `reason = …`), levels `debug` for
//! structural observations, `warn` for recoverable failures,
//! `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`),
//! component-prefixed messages (`"channel client: …"`,
//! `"open wrapper: …"`), `debug`/`warn` only.
@@ -27,7 +27,7 @@
//! - `alkgit`: no tracing at all at this writing.
//!
//! 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
//! lowercase with the `"decline: …"` / `"serving path …"` phrasing,
//! flat per-event fields (`feature_build`, `row`, `channel_id`,
@@ -37,91 +37,91 @@
//!
//! 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"`,
//! `"session+git"`, …; features joined in the order `session, git,
//! tunnel, sftp`), so a captured event is self-describing per
//! build. The `sftp` feature gates no route, but ships in the
//! 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"`),
//! the request name for request-level (e.g. `"pty_request"`,
//! `"tcpip_forward"`), the handler hook name for auth (e.g.
//! `"auth_publickey"`), the session-row hook names for the
//! 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 at this surface).
//!
//! Decline events additionally carry exactly:
//!
//! - `decline_class` — `"open"` | `"request"` | `"serving"` |
//! - `decline_class`: `"open"` | `"request"` | `"serving"` |
//! `"arrival"` | `"auth"`:
//! the two wire decline classes of `mapping.md` §Wire decline
//! reasons, plus the serving-path class (this wave the seam stub's
//! decline *is* the serving path; the wave-3 mappings emit the same
//! class from their real spawned paths) and the auth class (the
//! 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),
//! `"never-feature"` (ADR-003 §5 rows), `"cap-exceeded"`,
//! `"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
//! `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;
//! `w3-git-mapping` / `w3-tty-session`) and the tunnel rows' absent
//! fabric handle even under the feature (`w3-tunnel-direct`; the
//! unset-declines posture)),
//! `"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),
//! `"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),
//! `"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
//! ever rides — only the vocabulary value),
//! ever rides, only the vocabulary value),
//! `"exec-oversize"` (the pre-parse `max_exec_command_len` cap),
//! `"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
//! gate — the off-Unix `direct-streamlocal` decline, answered
//! gate: the off-Unix `direct-streamlocal` decline, answered
//! before any fabric call),
//! `"no-identity"` / `"channel-not-held"` (the git row's serving-
//! path guards — unreachable in practice, fail-closed; the tunnel
//! rows' open-level `no-identity` guard rides the same value —
//! path guards (unreachable in practice, fail-closed); the tunnel
//! rows' open-level `no-identity` guard rides the same value,
//! 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),
//! `"correlation-miss"` / `"cap-exceeded"` / `"accept-refused"` /
//! `"unvanilla-inbound"` (`w3-tunnel-listen`: the `-R` leg's arrival
//! declines — the class whose events carry the minted `listen` token
//! instead of a channel id — and the unvanilla inbound
//! declines, the class whose events carry the minted `listen` token
//! instead of a channel id, and the unvanilla inbound
//! `forwarded-tcpip` open),
//! `"listen-failed"` / `"no-such-listen"` (`w3-tunnel-listen`: the
//! listen dial's failed/bounded outcome and the cancel forms'
//! not-found pair) — the global-request and arrival rows, and on the
//! auth class `"method-unimplemented"` / `"cert-unsupported"` /
//! not-found pair), belonging to the global-request and arrival
//! rows; on the auth class, `"method-unimplemented"` / `"cert-unsupported"` /
//! `"non-ed25519"` / `"fingerprint-unresolved"`.
//! 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
//! registry is `w4-reverse-listen`'s) and `"never-feature"` (the
//! x11/agent rows — ADR-003 §5).
//! - `exec_route` — only on `exec_request`-row events: the dispatch
//! x11/agent rows, ADR-003 §5).
//! - `exec_route` rides only `exec_request`-row events: the dispatch
//! route the parse routed to (`"git"` | `"pipe"`), so a git row's
//! decline is distinguishable from a pipe row's in combined builds.
//! Other rows carry none (a `None` field is not recorded by
//! `tracing`).
//!
//! 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
//! 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
//! recoverable-failure level).
//!
@@ -129,11 +129,11 @@
//!
//! **No secret material ever enters an event; never the username.**
//! 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
//! path, key, or fingerprint could ride. In particular *the
//! 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
//! deployer-side computation over its own logs, never crate-emitted).
//! 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
//! request-argument detail (e.g. the git grammar's decline reasons in
//! `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.
/// 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
/// unfeatured parse route) or a not-yet-landed listen form. The wire
/// 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(
row: &'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
/// 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.
pub(crate) fn decline_auth(row: &'static str, reason: &'static str) {
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
/// this surface; the fields are otherwise the `request` vocabulary.
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-
/// 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
/// `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
/// 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
/// correlation number), never secret material.
#[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
/// stub (the wire shape pinned in `tests/spawn_return.rs`); the wave-3
/// 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"))]
pub(crate) fn decline_serving(
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
/// call. The mapping tasks' serving paths inherit this shape.
#[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
/// wire reporting has completed.
#[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
/// 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
/// ride the `error` field (§Safety posture); the mapping tasks emit
/// this from their real paths when serving fails before reporting.
/// (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.)
#[cfg(any(feature = "git", feature = "session"))]
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 {
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.
#[derive(Debug)]
#[cfg(any(feature = "git", feature = "session"))]
@@ -362,10 +362,10 @@ mod tests {
/// A minimal field-capturing visitor shared by these rows and the
/// 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
/// the convention is a hand-rolled minimal [`tracing::Subscriber`]
/// — no `tracing-subscriber` dependency).
/// with no `tracing-subscriber` dependency).
struct Visitor(Vec<(String, String)>);
impl tracing::field::Visit for Visitor {
@@ -379,7 +379,7 @@ mod tests {
}
/// 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
/// over the wire in `tests/observe.rs` against the global
/// capture).
@@ -425,8 +425,8 @@ mod tests {
.unwrap_or_else(|| panic!("no field {name} in {fields:?}"))
}
/// - *Contract stamp:* the event vocabulary (`w3-decline-tracing`)
/// — an open decline carries exactly the base shape, values
/// - *Contract stamp:* the event vocabulary (`w3-decline-tracing`):
/// an open decline carries exactly the base shape, values
/// included.
#[test]
fn decline_open_carries_the_full_vocabulary() {
@@ -449,11 +449,11 @@ mod tests {
"reason".to_owned(),
],
"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
/// by `tracing`).
#[test]
@@ -480,7 +480,7 @@ mod tests {
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
/// by `tracing`). The pty/shell lifecycle rows carry no route.
#[cfg(any(feature = "git", feature = "session"))]
@@ -503,9 +503,9 @@ mod tests {
assert_eq!(field(&fields, "error"), "Probe");
}
/// - *Contract stamp:* the arrival vocabulary (`w3-tunnel-listen`)
/// — an arrival decline carries the minted `listen` token
/// instead of a channel id (none exists yet — that is the point
/// - *Contract stamp:* the arrival vocabulary (`w3-tunnel-listen`):
/// an arrival decline carries the minted `listen` token
/// instead of a channel id (none exists yet; that is the point
/// of the declines; the token is bookkeeping metadata, never
/// secret material).
#[cfg(feature = "tunnel")]
@@ -530,7 +530,7 @@ mod tests {
/// The feature-build string: every enabled feature appears; a
/// 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.
#[test]
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.
//!
//! 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
//! immediate follow-up bytes (POC-3 proved by construction with
//! 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
//! (`Session::handle()`), hands it into its serving future, spawns,
//! and returns. No serving path is ever awaited inline; the dispatch
//! seam stubs ([`crate::handler`]) hang off this helper, so the
//! 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
//! 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;
//! [`report_exit_status_then_close`] preserves the wire ordering the
//! `mapping.md` `exit_status` row pins: `exit-status` sent **before**
@@ -28,7 +28,7 @@
//!
//! ## 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
//! `tests/spawn_return.rs` compose them over the real wire (a minimal
//! proof handler standing in for the crate's own auth/dispatch hooks,
@@ -41,7 +41,7 @@ use russh::ChannelId;
use russh::server::Handle;
/// 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.
/// Nothing here is ever awaited inline: an inline serve deadlocks
/// 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:
/// `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
/// spawned task). A failed send means the session is gone; nothing is
/// reportable then.
@@ -67,8 +67,8 @@ mod tests {
use super::spawn_serving_path;
/// - *Contract stamp:* ADR-006 decision 4 (spawn-and-return: the
/// handler call returns while the serving path is still running
/// — handler-returns-fast is structural).
/// handler call returns while the serving path is still running,
/// so handler-returns-fast is structural).
#[tokio::test]
async fn spawn_returns_while_the_serving_path_is_still_pending() {
let (release, waited) = tokio::sync::oneshot::channel::<()>();
@@ -82,7 +82,7 @@ mod tests {
assert!(
!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"
);
+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
//! `pty_request`/`shell_request` rows): pty/shell/pipe-exec serve
//! through the published alktty seam
//! (`drive_session_pre_negotiated(writer, reader, req, backends,
//! ownership, identity)`) over a **translation bridge, not a byte
//! 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
//! with small adapters around a pair of in-memory duplexes.
//!
@@ -15,7 +15,7 @@
//!
//! - `pty_request` args → `alktty::negotiation::TerminalParamsWire`
//! (`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
//! faithfully). Stored per channel (the POC-3 accepted-but-held
//! shape, [`crate::handler`]'s store) and consumed by the later
@@ -24,29 +24,29 @@
//! - `shell_request` → `NegotiateRequest{carriage: "raw", backend:
//! the seam selector, tty: the consumed pty params, cmd: the
//! 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
//! 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
//! vanilla), so a pty-less shell request is served, never declined.
//! - `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 =
//! Some(consumed pty)` when a pty preceded, else `None` (pipe
//! mode).
//! - **Unset-declines** (OQ-08, never a fallback): `shell_argv`
//! unset → `shell_request` declines; `exec_wrapper_argv` unset →
//! 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
//! 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
//! closes the way it would when the request itself cannot exist.
//! `session_seam` unset (feature on, seam absent) → the same
//! shape, reason `seam-unconfigured` (the decline matrix's
//! config-driven note); `pty_request` checks the seam too — a pty
//! whose session can never serve is a doomed allocation. Field
//! config-driven note); `pty_request` checks the seam too, since a
//! pty whose session can never serve is a doomed allocation. Field
//! checks precede the seam check (the row's own unset field is the
//! first decline); the decline never takes the channel and never
//! consumes stored pty state.
@@ -63,10 +63,10 @@
//! - **SSH → chunks** (the `client_recv` side of alktty's session):
//! a bounded [`tokio::sync::mpsc`] (the
//! [`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` →
//! `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
//! zero-length stdin chunk shuts the backend's stdin, *identical
//! in effect* to `ControlMessage::Eof` on `STREAM_CTRL_IN`; the
@@ -80,9 +80,9 @@
//! *error frame* is length-prefixed JSON whose first length byte
//! is `0x00`; a raw chunk's first byte is a `stream_type` ≤ 4).
//! stdout chunks → SSH `data`; stderr chunks → SSH
//! `extended_data(1)` (PTY mode merges upstream — the backend's
//! stderr stream is `None` there, so the translation is universal:
//! no mode-switch exists); the exit chunk (`STREAM_CTRL_OUT`) →
//! `extended_data(1)` (PTY mode merges upstream; the backend's
//! stderr stream is `None` there, so the translation is universal
//! and no mode-switch exists); the exit chunk (`STREAM_CTRL_OUT`) →
//! the derivation contract (§below). No unbounded buffering in
//! either adapter (AGENTS.md convention 9).
//! - **The negotiation-error frame is the bridge's serving-path
@@ -91,23 +91,24 @@
//! ownership), and a failure answers a length-prefixed error
//! frame instead of chunks. Decided + recorded: the bridge maps
//! that frame to a serving-path decline event
//! (`negotiation-failed`) and an **`exit-status(255)` then close**
//! — the one place this door claims 255: the session **never
//! (`negotiation-failed`) and an **`exit-status(255)` then close**,
//! the one place this door claims 255: the session **never
//! 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
//! error frame's JSON detail stays alktty-side; only the
//! vocabulary value rides the event.
//! - A bridge read that ends **without** an exit chunk reports
//! 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.
//! - **Known limit (recorded disposition, wave-3 gate booked item 3):
//! the stdout [`tokio::io::AsyncWriteExt::write_all`] carries no
//! deadline.** Backpressure is bounded (the bounded bridging duplex,
//! russh's per-channel window and the client's own flow control bound
//! 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
//! its stream, vanilla sshd's posture included), not a coverage
//! minor. So the limit is recorded, not "fixed".
@@ -116,7 +117,7 @@
//!
//! The requests arrive on the packet loop after the serving task
//! 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
//! forward_control `try_send`s the control chunk (never awaited on
//! the packet loop). `window_change_request` →
@@ -124,23 +125,23 @@
//! (alktty's uppercase-name vocabulary). Decisions, recorded:
//! the SSH control rows are fire-and-forget (no reply exists on
//! 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
//! 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
//! the backend's control handle, whose semantics decide), and the
//! pipe-mode pid-only vs PTY process-group *scope* is backend
//! 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
//! 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
//! bridge nor a stored pty is dropped.
//!
//! ## The exit derivation contract (mapping.md §Exit chunk, rules 1–6)
//!
//! 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:
//! the client rows consume wire exit reports back into the family's
//! `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**
//! (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
//! either, the task aborts the alktty session task — dropping its
//! not-yet-resolved `exit_code` future, which is alktty's
//! kill-on-drop contract firing (its ADR-056) — then closes the SSH
//! either, the task aborts the alktty session task, dropping its
//! not-yet-resolved `exit_code` future (alktty's
//! kill-on-drop contract firing, its ADR-056), then closes the SSH
//! channel and ends. Nothing is fabricated on a cancel. The
//! detached stdin/framing tasks are bounded by their duplex/mpsc/
//! 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::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).
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.
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.
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
/// `maximum_packet_size`, here by [`MAX_CHUNK_LEN`] on the frame).
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
/// 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).
pub fn terminal_params(
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
/// translation bridge).
pub(crate) enum BridgeFrame {
/// A raw alktty chunk (data or server→client control).
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
/// are alktty-side detail (never read here — only the
/// are alktty-side detail (never read here, only the
/// vocabulary value rides the decline event).
NegotiationError,
/// The half ended (alktty's task completed or was cancelled)
@@ -269,10 +270,10 @@ pub(crate) enum BridgeFrame {
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; anything ≤ 4 is a chunk `stream_type`. Bounds-checked
/// against [`MAX_CHUNK_LEN`] both directions — a rogue length never
/// byte; anything ≤ 4 is a chunk `stream_type`). Bounds-checked
/// against [`MAX_CHUNK_LEN`] both directions, so a rogue length never
/// triggers an oversized read.
pub(crate) async fn read_bridge_frame(
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
/// (`{"type":"exit","code":N}`). Anything else on `STREAM_CTRL_OUT`
/// has no contract meaning — and an exit chunk that cannot parse has
/// no fidelity input — both map to `None` (no report; the plain
/// has no contract meaning, and an exit chunk that cannot parse has
/// no fidelity input; both map to `None` (no report; the plain
/// close posture).
fn exit_chunk_code(payload: &[u8]) -> Option<i32> {
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`
/// / `signal` hooks reach the spawned bridge **through this struct**
/// (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
/// (single-writer; no lock).
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.
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
/// cancel.
pub(crate) cancel: watch::Sender<bool>,
}
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
/// closed surface drops the event.
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).
/// On EOF or a read error it sends **the zero-length stdin-chunk
/// sentinel** (the EOF translation decision: identical in effect to
/// alktty's `ControlMessage::Eof` — the backend's stdin shuts while
/// the session keeps pumping) and ends.
/// alktty's `ControlMessage::Eof`, with the backend's stdin shut
/// while the session keeps pumping) and ends.
async fn stdin_pump(
ssh_read: impl tokio::io::AsyncRead + Unpin + 'static,
chunks: mpsc::Sender<Chunk>,
@@ -450,9 +451,9 @@ async fn stdin_pump(
}
/// 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
/// 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.
async fn framing_task(mut chunks: mpsc::Receiver<Chunk>, writer: tokio::io::DuplexStream) {
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
/// 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).
async fn report_exit(
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 lifecycle events and the given reason. Used for `no-identity`.
async fn fail_and_close(
@@ -528,7 +529,7 @@ async fn fail_and_close(
/// [`alktty::drive_session_pre_negotiated`]; the alktty session task
/// 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
/// 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
/// stream and its report surface.
#[allow(clippy::too_many_arguments)]
@@ -688,14 +689,14 @@ async fn drive_chunks_to_ssh(
#[cfg(test)]
mod tests {
/// - *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
/// clean read-end classifies `Closed`, an oversized claimed
/// length classifies `Break` — bounds-checked both directions,
/// never an oversized read. `Break`'s serving arm is one body
/// length classifies `Break` (bounds-checked both directions,
/// never an oversized read). `Break`'s serving arm is one body
/// with `Closed`'s; through alktty's own validated `ChunkWriter`
/// 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 defensive arm at the frame layer.
#[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):
//! 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)
//!
//! 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` /
//! `direct-streamlocal` open, the target/substrate are constructed
//! from the SSH open arguments (`direct-tcpip`(host, port) →
//! `SubstrateAddr::HostPort` + `Substrate::Tcp`;
//! `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,
//! 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
//! the only honest flow) → on success `take_halves()` pumped against
//! 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 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
//! ([`crate::dispatch::connect_failed_open_decline`]; the stub itself
//! is retired). The channel is never established, so the SSH client
//! 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 server direction of the route-1 bridge (`mapping.md`
//! §Channel-type rows, the `forwarded-tcpip` row + §Global-request
//! rows). Per the family door semantics: the door owns nothing local
//! — **the fabric listens**; per arrival the data flows back toward
//! rows). Per the family door semantics: the door owns nothing local,
//! **the fabric listens**; per arrival the data flows back toward
//! the vanilla SSH client, which dials its own target. Three moving
//! 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`):
//! the door maps the vanilla listen request family-ward as
//! `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):
//! ADR-001 keeps the wire field a *resource identifier*, and a
//! fabric serving vanilla `-R` opt into dynamic-address binding by
//! its own establisher/bind-closure treating the mapped string as
//! its bind endpoint (`bindfirst_establisher` +
//! `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,
//! 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
//! `ChannelClient` — bounded by [`LISTEN_DIAL_TIMEOUT`]; the
//! `ChannelClient`, bounded by [`LISTEN_DIAL_TIMEOUT`]; the
//! reply's `bound` + minted `listen` token (the alkcall ADR-049
//! amendment-3 establisher fields) are read through
//! `parse-listen-reply`-class accessors; **a reply without both is
//! 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).
//! 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
//! fabric dials `tunnel/forwarded` *toward the door* per alktunnels'
//! 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
//! the door-allocated channel id the producer adopts; alkcall's
//! 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
//! `from_connection_with_serving`, whose registry the assembly owns
//! — so the serve-config carries [`crate::config::TunnelListenSeam`]
//! `from_connection_with_serving`, whose registry the assembly owns,
//! so the serve-config carries [`crate::config::TunnelListenSeam`]
//! (the same `Arc<OperationRegistry>` the assembly wired); alkssh
//! registers the forwarded openable itself in
//! [`install_forwarded_surface`] (called from
@@ -82,25 +85,34 @@
//! client's manager + alktunnels' `tunnel_forwarded_spec()` +
//! `make_forwarded_op_handler` over the crate's arrival pump, the
//! wire-level ACL (`tunnel:forwarded` scope) and the per-connection
//! channel budget enforced inside alkcall's dispatch.
//! 3. **The arrival pump** — the `forwarded-tcpip` data leg: the
//! channel budget enforced inside alkcall's dispatch, registered
//! 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
//! arrival's `listen` token through [`ListenRouter`] (the route was
//! 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`
//! 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
//! verbatim (the OpenSSH client matches that exact string; for
//! `port = 0` mints the *actually bound* port from the fabric's
//! `bound` reply — the sshd parity recorded in
//! [`ListenRoute`]) — with the `TunnelForwardedParams.peer` arrival
//! `bound` reply, the sshd parity recorded in
//! [`ListenRoute`]), with the `TunnelForwardedParams.peer` arrival
//! 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
//! 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
//! release rides [`crate::handler`]'s `channel_close` like every
//! established channel). Accept → the fabric `BiStream` pumped
@@ -113,7 +125,7 @@
//!
//! **The inline listen dial (recorded decision):** the
//! 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
//! return + the `&mut u32` out-param; no handle surface responds to a
//! 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
//! connection pause only for [`LISTEN_DIAL_TIMEOUT`], and 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.
//!
//! ## Bounded bookkeeping (AGENTS.md convention 9)
//!
//! - **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
//! args, bound port, the [`alktunnels::consumer::TunnelListener`]
//! whose `close`/`Drop` perform the fabric teardown). Its bound:
//! 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
//! would mint an unbounded fabric-channel map on the shared leg.
//! - **The route map** ([`ListenRouter`], shared with the registered
@@ -143,29 +155,29 @@
//! bounded by construction: entries are minted exactly at accepted
//! listens (listener-cap gated), removed 1:1 at the cancel forms
//! 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
//! half-open SSH channel).
//! - **A cancelled / torn-down listen never half-serves**: the cancel
//! path removes the route before any teardown (a racing arrival
//! 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 +
//! the bound port). One recorded caveat: an arrival that *cloned*
//! its route just before the cancel removes it still completes its
//! 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;
//! sshd parity).
//!
//! ## The runtime-Unix gate (OQ-11)
//!
//! `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**
//! (never a partial bridge), on-Unix the real bridge. The gate lives
//! 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
//! same way (`w3-tunnel-listen`).
//!
@@ -173,25 +185,25 @@
//!
//! The resolved SSH identity is the auth bridge's ride-along in
//! 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
//! like the git/tty rows' `w2-auth-bridge` parked item discharged
//! there). The guard gates every tunnel open **at the door**; the
//! open that proceeds then rides the channels connection into 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
//! `-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
//! arrivals are gated by the door-side registry's ACL
//! (`tunnel:forwarded` scope, per arrival) exactly when the assembly's
//! 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
//! 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
//! assembly-supplied `ChannelClient`'s own dial identity (CF-005: set
//! 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 alktunnels::consumer::{TunnelListener, TunnelSession};
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 russh::Channel;
use russh::server::{ChannelOpenHandle, Handle};
@@ -231,10 +246,10 @@ use crate::{config, dispatch, observe};
pub(crate) type WireChannel = Channel<russh::server::Msg>;
/// 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
/// 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
/// fire; the count recovers here, in the task that knows the failure,
/// 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 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
/// the rest over; the hook returns fast with the fabric open in
/// flight.
@@ -292,29 +307,29 @@ pub(crate) fn spawn_direct_row(
/// The wall-clock bound on the family-ward listen dial (`TunnelListener::
/// 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
/// loop longer than a bounded dial.
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
/// `-R` convention decoded (sshd parity, recorded on [`ListenRouter`]):
/// the connected address rides the wire `tcpip_forward` string
/// **verbatim** (the OpenSSH client sent that exact string and matches
/// 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
/// `port = 0` mints round-trip), and the unix path rides the
/// `streamlocal_forward` request verbatim.
#[derive(Clone, Debug)]
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
/// fields; `Unix` → the `forwarded-streamlocal` path).
pub(crate) echo: RouteEcho,
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
/// (the arrival's teardown-at-birth path).
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
/// ([`ListenRouter::register`], `w4-router-collision`). Arrivals
/// 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
/// 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.
pub(crate) struct ListenRouter {
routes: Mutex<HashMap<u32, ListenRoute>>,
@@ -352,21 +367,21 @@ pub(crate) struct ListenRouter {
impl ListenRouter {
/// Minted-entry registration (the listen-acceptance step),
/// 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`
/// registration): safety rests on the fabric's ADR-008 mint
/// 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
/// mint can never steal a live adapter's arrival routes. The
/// 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
/// rides [`String`]-carried detail surfaced as the request-time
/// **decline disposition** (`RequestDenied`, reason
/// `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
/// `Ok(false)`, and `Err` propagates out of the packet loop), and
/// the taxonomy's rule holds that wire decline reasons are never
@@ -378,7 +393,7 @@ impl ListenRouter {
let mut routes = self.routes.lock();
if routes.contains_key(&token) {
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"
));
}
@@ -386,7 +401,7 @@ impl ListenRouter {
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
/// session teardown remove.
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
/// 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.
pub(crate) fn cancel(&self, token: &u32) -> bool {
self.routes.lock().remove(token).is_some()
@@ -402,7 +417,7 @@ impl ListenRouter {
/// The owning session's teardown (the handler's `Drop`): remove
/// 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]) {
let mut routes = self.routes.lock();
for token in tokens {
@@ -423,24 +438,42 @@ impl Default for ListenRouter {
/// [`crate::SshAdapter::new`] when the fabric leg + seam are both
/// present): builds the router, registers alktunnels' forwarded
/// 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
/// channel allocation, ACL (`tunnel:forwarded`, per call) and budget
/// (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
/// cap shapes it through its own `ServingConfig`/`registry`
/// composition upstream) — and spawns alktunnels' forward-op handler
/// over the crate's arrival pump. The registration is
/// composition upstream), and spawns alktunnels' forward-op handler
/// 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
/// 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
/// 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).
///
/// 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.
pub(crate) fn install_forwarded_surface(serve: &mut config::ServeConfig) -> Result<(), String> {
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());
};
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 router = Arc::clone(&router);
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!(
"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",
alktunnels::params::OP_TUNNEL_FORWARDED
));
}
register_tunnel_forwarded_openable(
register_tunnel_forwarded_openable_with_establisher(
&core,
&seam.registry,
AuthContext::anonymous(b"alk/tunnel"),
gate,
handler,
)
.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(())
}
/// 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
/// alkcall openable registration hands every fabric arrival here with
/// the parsed [`TunnelForwardedParams`] and the arrival's `BiStream`;
/// 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
/// 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
/// 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
/// channel. The count releases here on every never-established
/// path; an established one releases at its close (the packet
/// 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
/// 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;
/// a rejected/dead-open releases the count and drops the fabric
/// 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).
async fn pump_forwarded_arrival(
router: Arc<ListenRouter>,
@@ -555,7 +609,7 @@ async fn pump_forwarded_arrival(
}
(RouteEcho::Tcp { address, port }, SubstrateAddr::Path { path }) => {
// 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.
route
.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
/// `TunnelListener::open`, bounded by [`LISTEN_DIAL_TIMEOUT`], with
/// the reply's `bound` + `listen` establisher fields required: a
/// 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).
pub(crate) struct MintedListen {
pub(crate) listener: TunnelListener,
@@ -599,9 +653,9 @@ pub(crate) struct MintedListen {
/// The listen dial (the mapping.md §Global-request rows' family-ward
/// 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
/// 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
/// the module doc).
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 `&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> {
match bound {
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
/// 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
/// 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).
pub(crate) fn listen_resource(endpoint: &str, substrate: Substrate, port: Option<u32>) -> String {
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 `channels`/`ptys` stores): the minted token is the map's key;
/// 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
/// 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
/// [`TunnelListener`] whose close/Drop performs the family teardown.
pub(crate) struct PendingListen {
@@ -673,16 +727,65 @@ pub(crate) struct PendingListen {
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
/// same `Limits::max_concurrent_channels` ceiling: listeners and
/// 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
/// 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
/// 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
/// cap's same posture).
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
name: "OQ-01 tracker — alk/ssh relay/browser-leg consumer"
name: "OQ-01 tracker: alk/ssh relay/browser-leg consumer"
status: pending
depends_on: []
scope: single
@@ -12,11 +12,11 @@ tags: [deferred-oq, external-trigger]
## 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
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
@@ -29,7 +29,7 @@ Transition check only: when a concrete consumer use case exists — a browser-WA
## 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
@@ -1,6 +1,6 @@
---
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
depends_on: []
scope: single
@@ -12,7 +12,7 @@ tags: [deferred-oq, external-trigger]
## 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
@@ -1,6 +1,6 @@
---
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
depends_on: []
scope: single
@@ -12,7 +12,7 @@ tags: [deferred-oq, external-trigger]
## 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
+2 -2
View File
@@ -1,6 +1,6 @@
---
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
depends_on: []
scope: single
@@ -12,7 +12,7 @@ tags: [deferred-oq, external-trigger]
## 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
+34 -33
View File
@@ -1,6 +1,6 @@
---
id: review-wave-1
name: Wave 1 gate — the foundations review
name: "Wave 1 gate: the foundations review"
status: completed
depends_on: [w1-manifest, w1-error-model, w1-key-sources, w1-host-key-policy, w1-fingerprint, w1-config, w1-test-doubles]
scope: narrow
@@ -11,7 +11,7 @@ level: review
## 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:
every later task implements against them, and the published crate
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
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
`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
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`.
- 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
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).
- 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).
- The error model — no catch-alls; auth declines are dispositions, not
- The error model: no catch-alls; auth declines are dispositions, not
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).
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)
- **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,
`rust-version = "1.89"`; `cargo tree -e normal` confirms **no
`rsa`, no `flate2`, no `ring`**, and the recorded
`ssh-key =0.7.0-rc.11` RC exception riding the russh keys path is
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
(`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)
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`,
`tests/doubles.rs` fixture rows) are green against the
`ssh-keygen -lf`-recorded manifest; `Limits::max_auth_attempts`
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 →
one-time `decode_secret_key`; the vault's taxonomy reshaped to the
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
`String` return) is documented as the ADR-005 decision-3 contract,
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
only as the double's explicit `with_default(true)` choice in tests —
correct posture); the crate is policy-blind.
- **OQ-08/09** ✓ — `shell_argv`/`exec_wrapper_argv` are `Option` with
only as the double's explicit `with_default(true)` choice in tests,
which is the correct posture); the crate is policy-blind.
- **OQ-08/09** ✓: `shell_argv` and
`exec_wrapper_argv` are both `Option` with
unset→decline semantics in the type docs; no construction-time
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 →
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,
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
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).
2. Vault-double shape — confirmed: `TestVault` wraps alkvault 0.1's
1. Sibling-dep timing: confirmed (see ADR-003 above).
2. Vault-double shape, confirmed: `TestVault` wraps alkvault 0.1's
own in-memory construction (`VaultServiceHandle::new()` +
`unlock_new`) rather than re-implementing behind the source trait,
so the real error taxonomy and GCM behavior ride every row (the
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,
with the `Err` channel held for wire/IO failures (recorded in
`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:
- `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
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.
- `key_source.rs` `HostKeySource` docs: stray `)` typo ("on host
*pin*) trust").
- `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.
- Two redundant test lines removed (a discarded pre-probe
`try_sign` in `key_source.rs`'s usability helper; a redundant
`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.
### 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;
all three recorded interpretations (sibling-dep timing, vault-double
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
test lines), recorded above. No shape errors in the one-way-door
surfaces. Wave 2 decomposition is unblocked; it should absorb the
rustdoc-hygiene lesson (run `cargo doc --no-deps` on gates) — no fix
tasks needed.
rustdoc-hygiene lesson (run `cargo doc --no-deps` on gates), though no fix
tasks are needed.
+39 -39
View File
@@ -1,6 +1,6 @@
---
id: review-wave-2
name: Wave 2 gate — the server-stack review
name: "Wave 2 gate: the server-stack review"
status: completed
depends_on: [w2-server-mount, w2-auth-bridge, w2-loopback-harness, w2-dispatch-decline-core, w2-spawn-return, w2-bounded-resources]
scope: narrow
@@ -13,39 +13,39 @@ level: review
The wave-2 review gate. The server stack is the substrate every
mapping composes into (wave 3 hangs three mappings and the sftp row on
it; wave 4 shares its foundations) — a shape error in the mount
it; wave 4 shares its foundations), so a shape error in the mount
signature, the auth-bridge state, or the dispatch routing costs churn
across all of them. The gate reads the landed stack against the
architecture line by line:
- **ADR-001** — the mount contract: `SshAdapter::handle` takes a
- **ADR-001**, the mount contract: `SshAdapter::handle` takes a
generic stream, mounts the full russh stack per stream, edge
termination only; no ALPN/relay code exists; the per-stream limits
posture holds.
- **ADR-002** — the Config construction (honest advertisement
- **ADR-002**, the Config construction (honest advertisement
`{publickey}` exactly; the knob mapping; no `des`/`dsa` posture
drift; the pin untouched).
- **ADR-003** — the feature gates as the dispatch's first `cfg`
- **ADR-003**, the feature gates as the dispatch's first `cfg`
consumers: flat, independent, `default = []` declines everything;
x11/auth-agent never feature-gated; honest advertisement per build.
- **ADR-004** — the dispositions in code (explicit declines for every
- **ADR-004**, the dispositions in code (explicit declines for every
non-publickey method; offered-hook accept-and-delegate; ed25519-only
explicit decline; fingerprint-only resolution; the identity rides
the session).
- **ADR-006** — parse-only dispatch (no detect residue); decline-first-
- **ADR-006**, parse-only dispatch (no detect residue); decline-first-
alloc-never structurally; **the spawn-and-return constraint pinned
by test, not convention**.
- **`mapping.md`** — the skeleton decline-matrix rows green per build
- **`mapping.md`**, the skeleton decline-matrix rows green per build
through the harness; the wire decline reasons vanilla-observable.
- **Bounded resources** — the cap enforced and recovering; the knobs
- **Bounded resources**, the cap enforced and recovering; the knobs
behaviorally verified or recorded; no unbounded shape anywhere.
- **The harness** — the contract-test vehicle convention established;
- **The harness**, with the contract-test vehicle convention established;
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
behavior (declining routes awaiting wave 3) documented as such.
- **The wave-1 gate's lesson absorbed** — `cargo doc --no-deps` runs
clean on this gate too.
- **The wave-1 gate's lesson absorbed**, with `cargo doc --no-deps`
running clean on this gate too.
Verdict: green (wave 3 decomposes, absorbing corrections) or findings
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)
- **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
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
leg, no `alk/ssh` string anywhere in `src/`. The per-stream limits
posture holds: each mounted stream builds its own `SessionHandler`
over the serve-config, so the cap/timeout/window budget is
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` /
`max_auth_attempts` verbatim; `inactivity_timeout` **always
`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
deliberations (`server_id` stays russh-default; keepalive stays
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!`
predicates (`session ∨ git` for session channels;
`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`
feature gates nothing and the subsystem row declines in every
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
**both** the signature algorithm and the key's algorithm before any
resolution → fingerprint → `resolve_from_fingerprint` →
accept-with-identity / reject fail-closed), the offered hook
accept-and-delegates, and the four non-publickey methods each have
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
are `Auth::Reject` dispositions end to end. The identity lands in
per-connection handler state
(`SessionHandler::resolved_identity`); its *session-level landing*
is wave-3 assertion territory (flagged in the task's notes — code
verifiable only through `auth_succeeded` consumption), confirmed as
is wave-3 assertion territory (flagged in the task's notes, as code
is verifiable only through `auth_succeeded` consumption), confirmed as
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
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:
every open-level decline answers before any accept; the only
pre-decline resource on any request path is none (exec parses via a
pure fn first), and the spawned serving paths' only allocation is
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
(`tests/spawn_return.rs`: handler-returns-fast under follow-up
traffic with the spawn still gated; exit-status strictly before
close, timestamps proving the report outlived the handler's
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
(`tests/dispatch_core.rs`: shared rows in every build; per-build
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`),
request-level capability declines answer `channel_failure` with
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 →
decline; close → open succeeds again, deterministic on the FIFO
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
shape anywhere: accepted-but-unserved session channels hold bounded
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
+ `connect_stream` (decision recorded in the module docs, over TCP
loopback), sibling-of-the-doubles placement under `tests/common/`,
the `drive` vocabulary the wave-3 mappings inherit, and the wave-1
doubles exercised live through it (`MemIdentityProvider`,
`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
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,
the store-and-take handoff, the wave-3 tunnel accept-flow), each
seam stub documents its wave-3 replacement point, and the
inherited-posture note (the bare `auth-agent` /
`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
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.
### 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
module docs name the wave-3 replacement explicitly, and the probe
rows pin exactly the POC-3 stage-A facts the skeleton needs
(unknown service → pipe row; total over arbitrary input).
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
serving-path decline shape (failure then close) is wire-pinned,
and the tunnel stub's `ConnectFailed` reason matches ADR-006
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
(`Construction(#[from] KeySourceError)` + `Wire(#[from]
russh::Error)`) with the locked→no-server rule at construction and
no catch-all; the wire decline reasons landed in the dispatch, not
in the error taxonomy (the wave-1 reservation spent correctly).
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)
@@ -212,7 +212,7 @@ Doc-hygiene only, in the task files (no `src/` change warranted):
- **No observability instrumentation on the decline paths.** 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
manifest per the ADR-002 toolkit but unused in `src/` so far). Small
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.
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.
### 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
declines, username structurally excluded), ADR-006's constraints
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,
the skeleton decline-matrix rows green per feature build through the
loopback harness, and docs that state what is deliberately deferred.
+51 -52
View File
@@ -1,6 +1,6 @@
---
id: review-wave-3
name: Wave 3 gate — the server-mappings review
name: "Wave 3 gate: the server-mappings review"
status: completed
depends_on: [w3-seam-config, w3-decline-tracing, w3-git-mapping, w3-tty-session, w3-tunnel-direct, w3-tunnel-listen]
scope: narrow
@@ -12,53 +12,53 @@ level: review
## Description
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
these rows, and the published crate freezes this surface. The gate
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;
direct-streamlocal; forwarded-tcpip; the global listen rows; the
request-level table) read against the landed code; the §exit
derivation contract rules 1–6 verified in code + rows; the decline
matrix green **per feature build** through the harness (including
the unset-field rows — feature on, handle unset).
- **ADR-006** — route-1 bridge (accept only after the fabric open
the unset-field rows, with the feature on and the handle unset).
- **ADR-006**, route-1 bridge (accept only after the fabric open
succeeds; `ConnectFailed` on failure), exec-only git (no subsystem
dispatch anywhere in the grammar), parse-only dispatch still
structural (no request-less serving), spawn-and-return over the
**real** serving paths (the wave-2 constraint re-pinned over
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
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.
- **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),
`set_identity` once-only mechanics recorded; the parked wave-2
`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
PTY-path fidelity limit documented), OQ-11 (runtime-Unix gates
compile-everywhere + clean off-Unix declines), OQ-13 (sftp rows
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
bounded, the control-row surfaces bounded, the bridge adapters
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
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
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
in `w3-tunnel-listen`; identity landing = discharged; the
inherited-auth-agent-open watch item = unchanged or re-read at any
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
(portable-pty's PTY-path exit shape; the `take_call_connection`
semantics reading).
@@ -84,103 +84,102 @@ booked (remediation before wave 4 decomposes).
- docs/plans/implementation.md (wave 3 section + this gate's role)
- docs/architecture/mapping.md, auth.md, client.md (the mirror),
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)
## Notes
### 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,
OQ-08 argv conventions, pipe-less shell serves pipe-mode), the git
exec row (full grammar, no-oracle validation, alkgit dispatch), the
direct-tcpip / direct-streamlocal rows (route-1 bridge), the
forwarded-tcpip row (echo args + bound port, teardown-at-birth on
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
request-level table, and the derivation contract rules 1–6 in
`derive_exit` (incl. `i32::MIN` via `checked_abs` → 255,
`core_dumped` never, rule 3's table entry unreachable by
construction, the PTY-path rule-5 limit documented). The decline
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).
- **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
decline), exec-only git (no subsystem dispatch anywhere in the
grammar; git service names decline), parse-only dispatch (no
detect residue), spawn-and-return re-pinned over the real serving
paths (the wave-2 mechanics rows still green; the inline listen
dial is the one recorded exception, bounded by
`LISTEN_DIAL_TIMEOUT` — wire-necessity reasoning documented).
- **ADR-003** ✓ — per-build matrices; honest advertisement
`LISTEN_DIAL_TIMEOUT` with the wire-necessity reasoning documented).
- **ADR-003** ✓, per-build matrices; honest advertisement
unchanged; the sftp feature still gates nothing and its rows
decline in every build (OQ-13); x11/auth-agent never-features
untouched; feature-to-dep isolation (`cargo tree` proven in
`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
row), tunnels (the CF-005/CF-006 establisher + ACL rows);
`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
discharged as ride-along store; no username anywhere (the
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
unconditionally), OQ-10 (derivation rules + PTY-path limit
documented), OQ-11 (runtime-Unix gates compile-everywhere with
dial-call-witness-at-zero off-Unix rows), OQ-13 (sftp rows
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
saturating release sites verified per row), the listener
bookkeeping bounded by a dedicated `max_concurrent_channels`-ceiling
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
surfaces bounded (`try_send`, never awaited on the packet loop),
the bridge adapters bounded (mpsc 64, 64 KiB duplexes, 16 KiB read
buffers, `MAX_CHUNK_LEN` frame gates). No unbounded shape found in
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 +
reason + exec_route; declines `debug`, the one `warn` the
serving-failure arm), safety posture pinned by scan (no username,
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
environment; every one carries a check-gated skip-with-reason
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-tunnel-listen`: harness accept hook + per-arrival cap); the
deferred identity-landing assertion discharged (each seam rides the
resolved identity, wire-witnessed); the inherited
`auth-agent`/`forwarded-streamlocal`-open watch item unchanged (no
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
resource-naming convention, the inline-dial bound, and
portable-pty's PTY-path exit shape are all recorded in module docs
(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
call; the flat single-field tunnel handle stays flat. Recorded in
`w3-seam-config`.
2. **The server-side sftp bullet already discharged in wave 2** —
confirmed: the rows are in `tests/dispatch_core.rs` per build.
3. **The `-R` listen flow's biggest open closed without a fork** —
2. **The server-side sftp bullet already discharged in wave 2**, confirmed: the rows are in `tests/dispatch_core.rs` per build.
3. **The `-R` listen flow's biggest open closed without a fork**,
confirmed: the topology closed over the published alkcall 0.10.1 /
alktunnels 0.4.1 surfaces via the `TunnelListenSeam`
course-correction; a scratch probe (published crates only) proved
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)
@@ -190,47 +189,47 @@ Source/doc fixes (all commit-tested; suite re-run green after):
construction** (the gate's one real fix): alkcall's
`OperationRegistry::register` silently *replaces* an existing op
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.
`install_forwarded_surface` now pre-checks the op
(`registry.registration(OP_TUNNEL_FORWARDED)`) and refuses with
`AdapterError::ListenRegistration`; new wire-level row
`a_second_adapter_over_a_shared_listen_registry_fails_construction`
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
*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
watch; the git task ends through the duplex, bounded by alkgit's
`session_wall_clock`). The cancel-form doc's recorded ordering
(router-before-map) was inverted vs the code; corrected, with the
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
just before a cancel completes independently (never half-open;
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).
- **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.
- **tests/dispatch_core.rs** — the stale
- **tests/dispatch_core.rs**, the stale
`unknown_git_services_route_to_the_pipe_row` name/doc superseded by
the w3 decision ("off the pipe row"); renamed, verdicts preserved
(the wire shape coincides — why it never failed).
- **handler.rs (git row)** — a pty-then-git-exec in a combined build
(the wire shape coincides, which is why it never failed).
- **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
(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;
`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
by construction** (the gate's only structural moderate):
`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` *inside* the seam call, before
`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
`exit_status_request` for an already-closed channel). A fix wants
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
this class: their report precedes any close-contracting drop in
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),
constructible on the wire (a second exec on a served channel),
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
no-exit-chunk close shape has no wire row; the tty stdout
`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):
`register` silently replaces on token collision; safety rests on
the fabric's ADR-008 mint discipline. A fail-closed
decline-the-listen-on-collision is a cheap wave-4 addition.
5. **Upstream asks to file** (booked with their owners, not this
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),
and mapping.md's recorded event-vocabulary nuance for the
unreachable mismatch arms (`correlation-miss` naming).
+57 -57
View File
@@ -1,6 +1,6 @@
---
id: review-wave-4
name: Wave 4 gate — the client-direction review
name: "Wave 4 gate: the client-direction review"
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]
scope: narrow
@@ -12,49 +12,49 @@ level: review
## Description
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
gate reads the landed client direction against the architecture 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
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
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;
`Disconnect::ByApplication`; **no reconnect/restore policy in the
crate** — the boundary held).
- **ADR-003 symmetry** — the same features gate both directions; the
crate**, a boundary held).
- **ADR-003 symmetry**, where the same features gate both directions; the
per-build matrices green client-side (the acceptor rows per build);
honest advertisement unchanged; the sftp placeholder declines in
every build both directions (OQ-13); x11/auth-agent never-features
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
consumed at the hook (crate default connects to nothing; no
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
(`w4-git-exit-close`), the channel-not-held wire rows
(`w4-channel-not-held-rows`), the tty coverage minors
(`w4-tty-coverage`), the router token-collision pre-check
(`w4-router-collision`). Item 5 (the upstream asks) verified still
booked with their owners — not absorbed silently.
- **Bounded resources** — no new unbounded shape on the client
booked with their owners, not absorbed silently.
- **Bounded resources**, with no new unbounded shape on the client
surfaces: the acceptor registry bounded, the listener bookkeeping
bounded, the pumps bounded (the convention-9 read over every new
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
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
silently green; anything named wave-5 e2e territory named
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
client-hook shapes at this pin; the acceptor-registry shape
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
criteria.
- **client.md line-by-line** ✓ — read against the landed code:
§The dial surface (`client.rs` — the four-step fail-closed
- **client.md line-by-line** ✓, read against the landed code:
§The dial surface (`client.rs`, the four-step fail-closed
sequence: decrypt-at-dial key load, the bounded russh `Config`
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 sanctioned `username` `DialConfig` amendment recorded honestly
— a required parameter, no crate default, never identity, never
logged); §Op rows (`client_ops.rs` — every table row present: the
(a required parameter, no crate default, never identity, never
logged); §Op rows, covering `client_ops.rs`, where every table row is present: the
exec/pty/shell rows under `session`, the direct rows under
`tunnel`, the git exec row under `git` over the shared grammar's
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
wait-pumps for the exit-consuming rows and straight
`into_stream` for the exit-less, the fire-and-forget controls
sent from the live pump, no queue consumption before any
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
over the live connection, listen rows gated pre-wire, the two
dedicated counters against the one ceiling); §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,
the no-reconnect/restore-policy boundary stated in module docs;
the crate owns no policy). client.md's landing blocks (acceptor/
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`,
`default = []`); the client exports ride the same gates
(`RemoteSession` under `session`, `TunnelChannel` + the acceptor
surface + `ReverseListener` under `tunnel`, `GitPackSession`
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
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);
honest advertisement unchanged; x11/agent never-features
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`
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`
carries host/port, the verdict passes through; `RejectAll`
connects to nothing, no accept-all built-in); the wave-1
`Zeroizing` wrap posture intact (`key_source.rs`, untouched).
- **The wave-3 gate's booked items 1–4** ✓ — all discharged:
item 1 (`w4-git-exit-close`) — the split-halves seam duplex
- **The wave-3 gate's booked items 1–4** ✓, all discharged:
item 1 (`w4-git-exit-close`), the split-halves seam duplex
(`ChannelHalveReader` over the owned close-free read half; no
drop path ever messages the wire) with the by-construction
property wire-pinned and the row falsifying the old shape
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
(`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
(Break's frame classification unit-rowed, wire-unreachable,
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
wire-time decline disposition, not a taxonomy variant that would
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
no-detach posture + the upstream ask), and mapping.md's
correlation-miss event-vocabulary nuance records stand.
- **Bounded resources** ✓ (convention 9 read over every new
client serving/pumping path) — the acceptor registry bounded by
- **Bounded resources** ✓, with the convention-9 read over every new
client serving/pumping path showing the acceptor registry bounded by
construction (two slots); the reverse-listen count and the
accepted-open handoff count are two dedicated `ChannelCap`
counters against the same `Limits::max_concurrent_channels`
@@ -174,37 +174,37 @@ criteria.
launch-drain cap, russh windows verbatim, the always-on wall
clock); the git row's command/repo gates answer pre-wire. One
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` /
`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`
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).
- **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
↔ server-session-rows smoke (`tests/client_ops.rs`, check-gated
on `sh`), the full-circle reverse-listen smoke
(`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.
- **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
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
crate-level sentence fixed inline (below).
### Minor issues found and fixed inline (this session)
- **The accepted-open handoff counter's panic-unsafe release** (the
gate's one code fix): the counter released only after
`acceptor.accept(...)` returned — an assembly acceptor panicking
inside `accept` stranded one held slot for the connection's
lifetime (a bounded-resource leak path on the new cap). Fixed
- **The accepted-open handoff counter's panic-unsafe release**, the
gate's one code fix: the counter released only after
`acceptor.accept(...)` returned, so an assembly acceptor panicking
inside `accept` would strand one held slot for the connection's
lifetime, a bounded-resource leak path on the new cap. Fixed
with a panic-safe guard (`HandoffCapGuard` in
`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
`client_handler.rs`'s two references; a unit row
(`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
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
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
registry grows exactly the two forwarded hooks; the split kept
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
instance, and the harness's `ForwardedAcceptor` vocabulary was
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
`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
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`
sequencing held, no cross-task file conflicts in the landed
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
interpretations confirmed except as recorded (all favorable); the
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
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
5 decomposition is unblocked and should absorb nothing beyond its
own plan (the decline matrix, real-tool e2e, fuzzing, docs, the
@@ -269,12 +269,12 @@ publish).
## 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
contract surfaces line by line; the wave-3 gate's booked items 1–4
discharged, item 5 verified still booked upstream; the full
verification set + `taskgraph validate` green. Two inline fixes
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`.
Wave 5 decomposition is unblocked.
+45 -45
View File
@@ -1,6 +1,6 @@
---
id: review-wave-5
name: Wave 5 gate — the release review
name: "Wave 5 gate: the release review"
status: completed
depends_on: [w5-decline-matrix, w5-e2e-server, w5-e2e-client, w5-fuzz-harness, w5-release-readiness]
scope: narrow
@@ -11,40 +11,40 @@ level: review
## 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
`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`)
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 ×
every build it can appear in × both directions pinned; the sftp
placeholder rows and the never-features explicit in every build;
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
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.
- **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
generator, the shared-library corpus replay in the verification
list); the campaign logs read (no unresolved crash/OOM/timeout);
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
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,
the decline-matrix behavior, the fingerprint format, the russh
pin's wire posture) confirmed accurate, because `w5-publish`
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
binary; single crate) verified against the landed state; the
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.
Verdict: green (`w5-publish` unblocked) or findings booked
@@ -80,27 +80,27 @@ Verdict: green (`w5-publish` unblocked) or findings booked
## Notes
**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
findings; nothing rose to booked-remediation level. Notes below.
### The matrix suite ✓
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
named in the audit table exist), and `mapping.md` §Decline matrix
traces cell by cell onto audit rows: the none build declines
everything (`every_channel_open_declines_in_a_default_build` + the
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
one serve (git-parseable exec); the session build's symmetric shape
(git-parseable exec declines, wrapper-unset declines, 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
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
pinned in every build both directions, with the `auth-agent` open's
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
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)
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`
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
event (the e2e_server rows). The task records' findings are all
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 e2e_client fixture/gating decisions (tunnel+session gating for
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
(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.
### 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
`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
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,
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
12M+ / key_decode 5M+ / terminal_params 9M+); the two first-campaign
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
finding: `decode_key_arm` now carries a crate-owned fixed message;
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
package excludes the sidecar (verified in the package list) and the
root workspace excludes it (workspace + package excludes in
@@ -151,40 +151,40 @@ root workspace excludes it (workspace + package excludes in
### Release readiness ✓
`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
summary + the doctest-mirrored examples) and the CHANGELOG 0.1.0
founding entry both read honest and match the landed surface. The
publish dry-run re-ran clean (76 files packaged, verification
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
contract: the four spec docs + ADRs 001–006 + README + the tracker),
`README`, `CHANGELOG`, both licenses, the manifests, and
`scripts/matrix.sh`; none of the excluded surface (AGENTS.md,
`.opencode/`, plans/research/reviews/sdd_process, tasks/, `fuzz/`)
appears. Manifest sanity: version 0.1.0 (the manifest value —
`w5-publish` verifies, no bump), edition 2024, `rust-version` 1.89,
appears. Manifest sanity: version 0.1.0 (the manifest value;
`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
(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
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 +
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
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
(`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
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
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
recorded in `w5-release-readiness.md` matches the landed code; the
publish freezes exactly those surfaces.
@@ -194,25 +194,25 @@ publish freezes exactly those surfaces.
The complete two-direction door shipped (server stack + all four
mapping rows + the decline placeholder; dial + op rows + the `-R`
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
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:
- wave-3 gate item 5 (two asks): the alktunnels/alkcall
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
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;
the actual tracker filing is owner-territory and stays listed
here so it cannot be absorbed at the publish.
### 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
fixtures, public test material, no custody posture applies) and
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)
`scripts/matrix.sh`: `cargo test` × 6 builds (none / git / session /
tunnel / sftp-only / --all-features — 151 / 195 / 210 / 201 / 151 /
281 tests passed, 0 failed; all integration binaries + doctests
tunnel / sftp-only / --all-features, with 151 / 195 / 210 / 201 /
151 / 281 tests passed, 0 failed; all integration binaries + doctests
included) · clippy (--all-targets -D warnings) ✓ · clippy
(--all-features --all-targets -D warnings) ✓ · `cargo fmt --check`
✓ · `cargo doc --no-deps` ✓ (plus `--all-features` re-run ✓) · the
@@ -236,13 +236,13 @@ completion statuses).
## 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
traced cell by cell onto `mapping.md`'s decline-matrix table with
every pinned test resolving in its binary and the `sftp`
no-op-feature fact + never-features verified in every build both
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
green, and all four final campaigns clean (the two first-campaign
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
crate, upstream asks still booked). One real code fix was already
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
set, the publish, the plan closure) closes it.
+37 -35
View File
@@ -1,6 +1,6 @@
---
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
depends_on: [w1-manifest, w1-key-sources, w1-host-key-policy]
scope: moderate
@@ -11,37 +11,38 @@ level: implementation
## 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:
- **Serve-config** (server direction, `mapping.md`/`overview.md`) —
the assembly-supplied serving configuration: the OQ-08 fields
(**shell argv** and **exec wrapper argv**, each `Option<Vec<String>>`
— unset → its row declines with a clean error, never a fallback, no
crate default; the wrapper form is platform-neutral), the
`IdentityProvider` handle (`Arc<dyn IdentityProvider>` — the
assembly injects it; the crate never resolves identities itself),
the host-key source handle (the wave-1 key-source seam), the tunnel
`ChannelClient` handle (the route-1 bridge's family-fabric entry —
`Option`, absent → tunnel rows decline even under the feature), and
the `Limits`. `cwd`/`env` in `NegotiateRequest` are assembly-supplied
serve-config values, never wire-derived (OQ-09 posture).
- **Dial-config** (client direction, `client.md` §The dial surface) —
address, key source (the wave-1 seam — never static keys, never env
- **Serve-config** (server direction, `mapping.md`/`overview.md`), the
assembly-supplied serving configuration: the OQ-08 fields
(**shell argv** and **exec wrapper argv**, each `Option<Vec<String>>`;
when unset, its row declines with a clean error, never a fallback,
no crate default; the wrapper form is platform-neutral), the
`IdentityProvider` handle (`Arc<dyn IdentityProvider>`, which the
assembly injects; the crate never resolves identities itself), the
host-key source handle (the wave-1 key-source seam), the tunnel
`ChannelClient` handle (the route-1 bridge's family-fabric entry,
held as an `Option`; when absent, tunnel rows decline even under
the feature), and the `Limits`. `cwd`/`env` in `NegotiateRequest`
are assembly-supplied serve-config values, never wire-derived
(OQ-09 posture).
- **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:
`keepalive_interval`/`keepalive_max` (15 s / 3 default posture per
the reverse-runner evidence), `inactivity_timeout`, window/packet
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
caps (consumed by the wave-3 grammar), and the russh knob mapping
with documented default postures — `max_auth_attempts` default 10
(sized for the counting `none`-probe, ADR-004 decision 6),
`window_size`/`maximum_packet_size`/`channel_buffer_size` set,
`inactivity_timeout` set (assembly-tunable). Every session carries
with documented default postures (`max_auth_attempts` default 10,
sized for the counting `none`-probe per ADR-004 decision 6;
`window_size`/`maximum_packet_size`/`channel_buffer_size` set;
`inactivity_timeout` set, assembly-tunable). Every session carries
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
(`Option` fields + doc contracts), not in construction-time
validation.
@@ -71,7 +72,7 @@ validation.
section)
- docs/architecture/client.md §The dial surface
- 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)
## Notes
@@ -82,25 +83,25 @@ validation.
>
> - **`Limits` is the single budget model both directions carry**
> (`Copy`, plain tuning data): the in-crate caps
> (`max_concurrent_channels` 64, `max_exec_command_len` 8192 —
> numbers chosen here, documented as assembly-tunable posture
> (`max_concurrent_channels` 64, `max_exec_command_len` 8192, with
> numbers chosen here and documented as assembly-tunable posture
> constants) plus the russh-knob mapping (`max_auth_attempts` 10
> per ADR-004 decision 6; `window_size` 2 MiB / `maximum_packet_size`
> 32 KiB / `channel_buffer_size` 100 — russh's own defaults, kept;
> 32 KiB / `channel_buffer_size` 100, russh's own defaults kept;
> `inactivity_timeout` 600 s carried as plain `Duration` so the
> "set (assembly-tunable)" posture is structural — wave 2/4 map it
> to russh's `Option` as always-`Some`;
> "set (assembly-tunable)" posture is structural, with wave 2/4
> mapping it to russh's `Option` as always-`Some`;
> `keepalive_interval`/`keepalive_max` Some(15 s)/3 riding inside as
> the client-direction posture, satisfying the dial-config
> criterion without a duplicate knob set). russh's rekey `Limits`
> stay russh-enforced at their RFC-4253 defaults and are not
> carried (documented; wave-2 material).
> - **Dial-config carries the knobs via its `Limits` field** — the
> - **Dial-config carries the knobs via its `Limits` field**: the
> criterion's "keepalive/inactivity/window knobs with the
> documented defaults" read as the budget model riding the config;
> the accepted criterion on `Limits` itself pins that placement,
> so a second knob set on `DialConfig` would have duplicated state.
> - **No `Default` for the two configs** — the required handles
> - **No `Default` for the two configs**: the required handles
> (`Arc<dyn IdentityProvider>`,
> `Arc<dyn HostKeySource>`, `Arc<dyn KeySource>`,
> `Arc<dyn HostKeyPolicy>`) have no crate-defaultable shape
@@ -111,12 +112,12 @@ validation.
> + doc contracts**; no construction-time validation (the
> `error.rs` no-config-error contract). Two request-time check
> helpers exist (`shell_configured`/`exec_wrapper_configured`,
> plus `tunnel_configured`) for the wave-2 dispatch to call —
> plus `tunnel_configured`) for the wave-2 dispatch to call,
> reading the `Option`, never validating.
> - **`cwd`/`env` (OQ-09 posture) carried on `ServeConfig`** as
> `Option<PathBuf>` / `BTreeMap<String, String>` (empty defaults;
> never wire-derived — doc contract).
> - **`channel_client` is not feature-gated** — alkcall is a base
> never wire-derived; doc contract).
> - **`channel_client` is not feature-gated**: alkcall is a base
> dependency and a field presence is not a capability; the `tunnel`
> feature gate applies at wave 2 on top (absent → decline even
> under the feature; unfeatured → decline too). Doc contract
@@ -124,7 +125,7 @@ validation.
> - Verified the alkcall 0.10 surfaces in-source (registry):
> `core::auth::IdentityProvider` is a sync,
> `Send + Sync + 'static` trait (dyn-compatible with no
> async-trait help on alkssh's side — `ChannelClient` and the
> async-trait help on alkssh's side; `ChannelClient` and the
> `NoopIdentityProvider` test fixture ride
> `alkcall::channels::client` / `alkcall::core::auth`), and the
> russh 0.65.0 knob names (`window_size`, `maximum_packet_size`,
@@ -137,7 +138,8 @@ validation.
> Config surface landed: `src/config.rs` with `Limits` (in-crate
> caps: `max_concurrent_channels` 64, `max_exec_command_len` 8192;
> russh knobs: `max_auth_attempts` 10, window 2 MiB, packet 32 KiB,
> buffer 100, inactivity 600 s, keepalive Some(15 s)/3 — every number
> buffer 100, inactivity 600 s, keepalive Some(15 s)/3, with every
> number
> a named posture constant or documented ADR anchor), `ServeConfig`
> (OQ-08 `shell_argv`/`exec_wrapper_argv` as unset-declines
> `Option<Vec<String>>`, OQ-09 `session_cwd`/`session_env`,
+19 -19
View File
@@ -1,6 +1,6 @@
---
id: w1-error-model
name: Wave 1 — the error model (fail-closed taxonomy)
name: "Wave 1: the error model (fail-closed taxonomy)"
status: completed
depends_on: [w1-manifest]
scope: narrow
@@ -12,25 +12,25 @@ level: implementation
## Description
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):
- **`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
adapter, **never collapsed**: `Locked` (vault locked → no dial, and
no server: a host key that can't load prevents server
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-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
per variant, including the fail-closed consequence of each.
- Deliberately **not** error types: auth declines (dispositions —
`Auth::Reject`, ADR-004; never errors), wire decline reasons
(SSH-shaped responses — wave-2 material), config validation (the
- Deliberately **not** error types: auth declines (dispositions, the
`Auth::Reject` of ADR-004; never errors), wire decline reasons
(SSH-shaped responses, wave-2 material), config validation (the
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).
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
> Payload shapes pinned (flagged for the wave-1 gate): `Locked` and
> `Absent` are payload-free — no honest content beyond the variant
> itself (the caller knows which source it requested);
> `Absent` are payload-free, with no honest content beyond the
> variant itself (the caller knows which source it requested);
> `Decryption(String)` and `UnknownKeyVersion(String)` carry the
> vault's opaque messages (`VaultServiceError::Encryption` /
> `InvalidPath` arrive as strings per POC-2 §3 — string is the honest
> content). `Decode` carries `String` rather than the typed
> `InvalidPath` arrive as strings per POC-2 §3, where a string is the
> honest content). `Decode` carries `String` rather than the typed
> `ssh_key::Error` (`russh::keys::decode_secret_key`'s error type):
> 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
> module doc comment records the deliberately-not-error classes
> (auth declines = dispositions per ADR-004; wire decline reasons =
> wave-2; config validation = none unless w1-config finds a need —
> the OQ-08 fields decline at request time). No `From<VaultServiceError>`
> wave-2; config validation = none unless w1-config finds a need,
> as the OQ-08 fields decline at request time). No `From<VaultServiceError>`
> blanket impl on purpose: alkvault's taxonomy has variants
> (`Mnemonic`, `Derivation`, `AlreadyUnlocked`, `UnsupportedKeyType`)
> that do not map onto the five without collapsing, and the ADR's
> 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
> 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
> 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/
> unknown-version/decode → no dial; host-key path → no server
> 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
> taxonomy. Four unit tests (exhaustive match over the enum,
> `Display`, `Debug`, `Clone`). Verified: `cargo test` (4/4 + doc
+13 -13
View File
@@ -1,6 +1,6 @@
---
id: w1-fingerprint
name: Wave 1 — fingerprint computation (the contract invariant)
name: "Wave 1: fingerprint computation (the contract invariant)"
status: completed
depends_on: [w1-manifest]
scope: narrow
@@ -12,23 +12,23 @@ level: implementation
## Description
Land the fingerprint computation the auth bridge (server) and the
host-key policy path (client) both consume — ADR-004 decision 1's
contract invariant in code:
host-key policy path (client) both consume; this is ADR-004
decision 1's contract invariant in code:
- Compute a key's fingerprint via the `ssh-key` re-export
(`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`,
alkcall-side computation) must compare equal to what this crate
computes; parity is proven, not assumed (POC-1 §1).
- The function takes the russh/ssh-key public-key type and returns the
string; it is the single fingerprint path in the crate (no
second formatting anywhere — a drift here breaks every identity
resolution silently).
second formatting anywhere, since a drift here breaks every
identity resolution silently).
- Parity tests against independent-issuer vectors: static ed25519
test keys with their `ssh-keygen -lf`-recorded expected fingerprints
(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
@@ -42,7 +42,7 @@ contract invariant in code:
## References
- docs/architecture/decisions/004-auth-dispositions.md (decision 1 —
- docs/architecture/decisions/004-auth-dispositions.md (decision 1:
the format invariant)
- docs/architecture/auth.md §The bridge (step 2)
- docs/research/poc-1-auth-bridge.md §1 (the parity checkpoint)
@@ -51,7 +51,7 @@ contract invariant in code:
## Notes
> 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
> re-exports `ssh_key::{PublicKey, HashAlg}` via `russh::keys`), so
> 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
> formatting path (doc mentions only). The russh 0.65 handler shape
> feeds exactly this type: `VerifiedPublicKey.public_key: &
> ssh_key::PublicKey` — the auth bridge (wave 2, w1-host-key-policy)
> passes that field straight through.
> ssh_key::PublicKey`, with the auth bridge (wave 2,
> w1-host-key-policy) passing that field straight through.
>
> **Fact-check finding (doc correction landed):** the ADR-004 /
> auth.md / task wording "unpadded **url-safe** base64" is a misnomer.
> The ssh-key crate's `Fingerprint` Display emits `SHA256:` +
> *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
> invariant was never "url-safe"; it is byte-parity with standard
> issuers, which POC-1 proved and the committed vectors now pin.
@@ -85,7 +85,7 @@ contract invariant in code:
## 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
> ssh-key re-export behind russh 0.65's keys module with
> `HashAlg::Sha256`. Parity proven, not assumed: 4 committed
+14 -13
View File
@@ -1,6 +1,6 @@
---
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
depends_on: [w1-manifest]
scope: narrow
@@ -12,27 +12,28 @@ level: implementation
## Description
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
decides accept/reject. The two OQ-05 cases are the *implementor's*:
case-1 pins (known beforehand — hard-decline on mismatch) and case-2
policies (unknown beforehand — prompt-and-record, learn-and-record,
case-1 pins (known beforehand, hard-decline on mismatch) and case-2
policies (unknown beforehand: prompt-and-record, learn-and-record,
provider-metadata match, accept-and-continue for ephemeral fleets).
The crate is **policy-blind**: it never decides trust, never
hard-codes a trust model.
- **The crate default is fail-closed**: reject unknown (russh's own
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 trait receives the fingerprint string (the wave-1 fingerprint
computation is the caller's input path, wave-4 dial); the exact
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
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
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.
## 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/client.md §The dial surface, §What the crate does
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)
- 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
> the russh 0.65 hook shape (`Handler::check_server_key ->
> 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
> 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
> error (`src/error.rs` contract); decline reasons are the
> 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).
> - **The trait is async and dyn-compatible**: `#[async_trait]`,
> `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`
> precedent), which w1-config's dial-config handle and
> w1-test-doubles' inspectable double both require. State-holding
> (learn-and-record) policies record by interior mutability
> behind the shared `&self` handle.
> - **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
> `async-trait` feature, per ADR-002's context). Precedented and
> deduped: alkcall already carries `async-trait 0.1`, so the tree
+20 -19
View File
@@ -1,6 +1,6 @@
---
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
depends_on: [w1-manifest, w1-error-model]
scope: moderate
@@ -11,33 +11,33 @@ level: implementation
## 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):
- **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
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).
- **At-use lifecycle** (decision 2) — `vault.decrypt(blob)` →
**`Zeroizing<String>` wrap at the adapter seam** (decision 3 — the
- **At-use lifecycle** (decision 2): `vault.decrypt(blob)` →
**`Zeroizing<String>` wrap at the adapter seam** (decision 3, the
known-gap contract; the vault-side improvement is upstream, not
here) → one-time `decode_secret_key(plain, None)` → the decoded
`PrivateKey` lives for the dial's lifetime. The vault is needed only
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
consume (the `Config` construction itself is wave 2; locked vault →
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
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
adapter seam onward.
Inner format is OpenSSH private-key PEM, unencrypted (decision 1) —
the source contract assumes it; the decode call is the single
Inner format is OpenSSH private-key PEM, unencrypted (decision 1);
the source contract assumes it, and the decode call is the single
conversion.
## Acceptance Criteria
@@ -78,31 +78,31 @@ conversion.
> a *set* because `Config::keys` takes multiple). An assembly may
> implement either trait itself; the crate ships the ADR-005
> 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
> `Option<EncryptedData>` record (`None` → `Absent`, the POC-2 §1
> `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
> (`decrypt_and_decode`, shared): `vault.decrypt(blob)` →
> **`Zeroizing<String>` at the seam** → one-time
> `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` →
> `Locked`; `InvalidPath` → `UnknownKeyVersion`; `Encryption` →
> `Decryption`. The vault's decrypt-path-unreachable variants
> (`Derivation`/`Mnemonic`/`AlreadyUnlocked`/`UnsupportedKeyType`)
> 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.
> - **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):
> round-trip (encrypt PEM → source → fingerprint-identical decodable
> ed25519 key that signs), dyn-handle load, host-key set order,
> load-works-after-lock (POC-2 §2 lifecycle), locked / absent /
> 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
> host-bootstrap refusal, empty host set, v2→v3 rotation-preserves
> -identity through the source. Key fixtures: three committed
@@ -112,12 +112,13 @@ conversion.
>
> Interpretation pinned for test-doubles: alkvault 0.1 *does* ship
> 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
> `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
> vault-backed implementations carrying decrypt-at-use (vault.decrypt
> → `Zeroizing<String>` → one-time `decode_secret_key`), opaque
+29 -28
View File
@@ -1,6 +1,6 @@
---
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
depends_on: []
scope: moderate
@@ -11,18 +11,19 @@ level: implementation
## Description
Land the implementation dependency set and the ADR-003 feature graph —
the first commit of wave 1; everything else lands inside it. Per
Land the implementation dependency set and the ADR-003 feature graph:
the first commit of wave 1, with everything else landing inside it.
Per
ADR-002 decision 6 ("manifest lands with the dependency") and
`overview.md` §lifecycle note:
- **`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:
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
exception).
- **`alkcall 0.10`** (identity/ACL types at the wire layer —
ed25519-only; the tree must drop `rsa`/`pkcs1` RC deps entirely,
with `ssh-key =0.7.0-rc.11` riding the russh keys path as the
recorded exception).
- **`alkcall 0.10`** (identity/ACL types at the wire layer:
`IdentityProvider`, `Identity`, `AccessControl`), **`alkvault 0.1`**
(the ADR-005 custody model). Both pinned by version, never
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
current 1.88/2021 manifest).
- **The ADR-003 feature graph declared from the first commit**:
`session`, `git`, `tunnel`, `sftp`, `default = []` — flat,
independent, no feature implies another. Features gate nothing yet
(the wave-2 decline core is their first consumer); the per-feature
sibling deps (`alktty` under `session`, `alktunnels` under `tunnel`,
`alkgit` under `git`) land with their first consuming wave, not as
dead manifest entries — recorded interpretation, flagged for the
wave-1 gate.
`session`, `git`, `tunnel`, `sftp`, `default = []`; the graph is
flat, independent, and no feature implies another. Features gate
nothing yet (the wave-2 decline core is their first consumer); the
per-feature sibling deps (`alktty` under `session`, `alktunnels`
under `tunnel`, `alkgit` under `git`) land with their first
consuming wave, not as dead manifest entries (recorded
interpretation, flagged for the wave-1 gate).
- Update the manifest comments (the current "deliberately empty until
implementation start" notes are superseded); keep comments to the
non-obvious-constraint class only.
Pre-flight: verify every pin exists on crates.io at the declared
version (`russh 0.65`, `alkcall 0.10`, `alkvault 0.1`); if a
crate/version is not published, stop and escalate — a blocking issue,
not something to work around.
crate/version is not published, stop and escalate, since a missing
pin is a blocking issue, not something to work around.
## Acceptance Criteria
@@ -66,24 +67,24 @@ not something to work around.
- docs/architecture/decisions/002-russh-pin.md (decisions 1–6)
- docs/architecture/decisions/003-feature-graph.md (decision 1, §2)
- 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)
## Notes
> Pre-flight executed: `cargo info` confirmed `russh 0.65.0`,
> `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
> 0.10.1; the caret stops at the minor, matching the ADR-002-russh
> convention for family pins). Toolkit versions: `thiserror 2.0`,
> `tracing 0.1`, `parking_lot 0.12`, `zeroize 1.9`,
> `alkcall 0.10.0`, and `alkvault 0.1.0` all published on crates.io,
> so there is no blocking issue. `alkcall` pinned as `"0.10"` (latest
> in-range is 0.10.1; the caret stops at the minor, matching the
> ADR-002-russh convention for family pins). Toolkit versions:
> `thiserror 2.0`, `tracing 0.1`, `parking_lot 0.12`, `zeroize 1.9`,
> `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
> gate as the task states; no dead manifest entries). Manifest
> comments trimmed to the non-obvious-constraint class: the
> russh/`default`-features posture (ADR-002 — must not be re-enabled
> by a future editor) and the fail-closed feature-graph note (ADR-003).
> russh/`default`-features posture (ADR-002, which must not be
> re-enabled by a future editor) and the fail-closed feature-graph note (ADR-003).
## Summary
@@ -94,8 +95,8 @@ not something to work around.
> `edition = "2024"`, `rust-version = "1.89"`; ADR-003 feature graph
> (`session`/`git`/`tunnel`/`sftp`, `default = []`, flat and
> independent). Verified: `cargo tree` contains no `rsa`, `pkcs1`, or
> `flate2` — only the recorded `ssh-key 0.7.0-rc.11` exception riding
> the russh keys path; `cargo test`, `cargo test --all-features`,
> `flate2` (only the recorded `ssh-key 0.7.0-rc.11` exception riding
> the russh keys path); `cargo test`, `cargo test --all-features`,
> `cargo clippy --all-targets -- -D warnings` (default and
> all-features), and `cargo fmt --check` all pass. `taskgraph
> validate` clean.
+30 -26
View File
@@ -1,6 +1,6 @@
---
id: w1-test-doubles
name: Wave 1 — test doubles (the in-memory seams)
name: "Wave 1: test doubles (the in-memory seams)"
status: completed
depends_on: [w1-error-model, w1-key-sources, w1-host-key-policy]
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
against:
- **In-memory `IdentityProvider`** — registered fingerprints →
- **In-memory `IdentityProvider`**: registered fingerprints →
`Identity` resolutions over alkcall's trait; the double the auth
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
the key sources consume, so the custody tests run without a live
vault service. If alkvault 0.1 ships an in-memory construction, wrap
it; otherwise a local test double behind the source trait — the
task pins which, flagged for the wave-1 gate.
- **In-memory `HostKeyPolicy`** — a configurable record/verdict double
it; otherwise a local test double behind the source trait (the
task pins which, flagged for the wave-1 gate).
- **In-memory `HostKeyPolicy`**: a configurable record/verdict double
(accept/reject per host, decision log inspectable) for the dial and
acceptor tests.
- **ed25519 test-key fixtures** — throwaway test keys (generated at
test time or committed static PEMs — the task pins which; committed
keys are throwaway fixtures, not secrets) with recorded
- **ed25519 test-key fixtures**: throwaway test keys (generated at
test time or committed static PEMs, with the task pinning which;
committed keys are throwaway fixtures, not secrets) with recorded
`ssh-keygen -lf` fingerprints, shared with the fingerprint parity
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
double reproduces)
- 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
- **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
**wrapper** (`TestVault` in `tests/common/mod.rs`) around that
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 /
absent / tampered / wrong seed) ride the real seams.
- **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
halves are throwaway fixtures by the task's own posture, documented as
such in `tests/vectors/fingerprints.txt` and in the common module docs.
- **Location decision (recorded):** the doubles live only under
`tests/common/mod.rs`, reached with `mod common;` — the alkgit
convention, adapted. Nothing doubles-related is reachable from
`tests/common/mod.rs`, reached with `mod common;` (the alkgit
convention, adapted). Nothing doubles-related is reachable from
`lib.rs`; no feature gates them. `src/`-unit tests that need the
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
module-wide in `tests/common/` (each test binary compiles the whole
shared module and never exercises all of it).
- **The fixture set was unified and re-minted (flagged for the gate):**
the previous state had two overlapping throwaway sets — public-only
vectors under `tests/vectors/` (fingerprint parity) and inline
private-PEM constants in `src/key_source.rs` — and the inline PEM
the previous state had two overlapping throwaway sets (public-only
vectors under `tests/vectors/`, used for fingerprint parity, and
inline private-PEM constants in `src/key_source.rs`), and the inline
PEM
constants, while decodable by russh, were **rejected by OpenSSH
itself** (`ssh-keygen` → "error in libcrypto"): unusable as
cross-issuer fixtures. All four keys (`alpha`/`bravo`/`charlie`/
`delta`) were freshly minted with OpenSSH_9.6p1's ssh-keygen, their
fingerprints recorded byte-verbatim in `tests/vectors/fingerprints.txt`,
and the two sets unified into one fixture table (`FIXTURE_KEYS`) —
committed `.key` + `.pub` files, served to the fingerprint parity
and the two sets unified into one fixture table, `FIXTURE_KEYS`
(committed `.key` + `.pub` files, served to the fingerprint parity
tests, the vault round-trip, and every later auth/mapping test. The
old vector-only set was retired (its keys had no private halves).
- The row/stamp discipline is established in `tests/common/mod.rs`'s
module docs (adapted from the sibling crates): rows state properties
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).
- No `src/` non-test changes: `src/key_source.rs` changed only inside
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
`MemIdentityProvider` (registered fingerprints → alkcall `Identity`
resolutions; token path resolves none — ADR-004's fingerprint-only
disposition), `TestVault` (a wrapper over alkvault 0.1's shipped
in-memory construction — the pinned resolution of the flagged
resolutions; the token path resolves none (ADR-004's
fingerprint-only disposition)), `TestVault` (a wrapper over alkvault 0.1's shipped
in-memory construction, the pinned resolution of the flagged
vault-double question), `MemHostKeyPolicy` (exact `(host, fingerprint)`
rules + explicit default verdict — fail-closed by default, permissive
only by explicit choice — with a fully inspectable decision log), and
rules plus an explicit default verdict; fail-closed by default and
permissive only by explicit choice, with a fully inspectable decision
log), and
the unified ed25519 fixture table (`FIXTURE_KEYS`: four committed
`ssh-keygen`-minted OpenSSH-PEM key pairs with recorded
`ssh-keygen -lf` fingerprints in `tests/vectors/fingerprints.txt`). The
+35 -34
View File
@@ -1,6 +1,6 @@
---
id: w2-auth-bridge
name: Wave 2 — the auth bridge (dispositional handler surface)
name: "Wave 2: the auth bridge (dispositional handler surface)"
status: completed
depends_on: [w2-server-mount, w1-fingerprint, w1-test-doubles]
scope: moderate
@@ -11,32 +11,32 @@ level: implementation
## 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:
- **`auth_publickey`** (fires post-verification; the key arrives as
`VerifiedPublicKey.public_key` with the signature `.algorithm`
exposed): non-ed25519 algorithm → **explicit reject** (ADR-002
decision 4 — the explicit-over-inherited pattern, so the posture
decision 4, the explicit-over-inherited pattern, so the posture
survives a pin bump); ed25519 → fingerprint via the crate's single
fingerprint path (`src/fingerprint.rs`, the ADR-004 contract
invariant) → `IdentityProvider::resolve_from_fingerprint` →
`Some(identity)` → `Auth::Accept` with the resolved identity stored
in the per-connection handler state (it rides the session for the
wave-3 seam propagation — `auth_succeeded` is the ride-along hook);
wave-3 seam propagation; `auth_succeeded` is the ride-along hook);
`None` → reject fail-closed. The resolved-identity-`None` path never
creates a weakly authorized session.
- **`auth_publickey_offered`** — accept-and-delegate (the russh
- **`auth_publickey_offered`**: accept-and-delegate (the russh
default shape at this pin; keep it explicit so the counting stays
honest — the hook does not consume an attempt, POC-1 verified).
- **Explicit declines** — `auth_none`, `auth_password`,
honest (the hook does not consume an attempt, POC-1 verified).
- **Explicit declines**: `auth_none`, `auth_password`,
`auth_keyboard_interactive`, `auth_openssh_certificate` each get an
explicit `Auth::reject()` implementation (family contract, not
inherited defaults — a pin bump could silently change them).
- **The username carries no semantics** (OQ-03) — resolution is
inherited defaults, which a pin bump could silently change).
- **The username carries no semantics** (OQ-03): resolution is
fingerprint-only; the user string is never an identity input.
Auth declines are *dispositions* (`Auth::Reject`), not errors — the
Auth declines are *dispositions* (`Auth::Reject`), not errors, so the
error model stays key-source-only. Tests run against the wave-1
doubles (`MemIdentityProvider` + the ed25519 fixture keys) at the
handler level; the loopback harness proves the bridge end to end over
@@ -65,7 +65,7 @@ the wire in `w2-loopback-harness`.
dispositions, decisions 1–5)
- docs/architecture/auth.md §Server direction (the bridge steps; the
0.65.0 `VerifiedPublicKey` shape)
- docs/architecture/decisions/002-russh-pin.md (decision 4 —
- docs/architecture/decisions/002-russh-pin.md (decision 4;
ed25519-only, the explicit-decline rationale)
- docs/plans/implementation.md §Wave 2 (the auth-bridge bullet)
- russh 0.65.0 in-source: `Auth`, `VerifiedPublicKey`,
@@ -77,37 +77,37 @@ Implementation record (decisions made at this task's discretion):
- **The bridge core is structured out of the hook on purpose.**
`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`
hooks cannot be invoked directly from a test. The bridge is therefore
`SessionHandler::resolve_bridge(&public_key, &signature_algorithm)` —
the ed25519-only gate → the single fingerprint path →
`resolve_from_fingerprint` — a plain method the contract tests drive
`SessionHandler::resolve_bridge(&public_key, &signature_algorithm)`,
spanning the ed25519-only gate → the single fingerprint path →
`resolve_from_fingerprint`; a plain method the contract tests drive
with ordinary key material, and the hook is one line over it (call,
store on `Some`, `Auth::reject()` on `None`). The non-constructible
hooks are exercised as full hooks over the wire instead (see below).
- **Two test vehicles, both recorded.** Wire rows (the real
`SshAdapter` over a tokio-duplex pair + russh's own client — the
`w2-server-mount` wire-smoke precedent) drive the full
`SshAdapter` over a tokio-duplex pair + russh's own client (the
`w2-server-mount` wire-smoke precedent)) drive the full
`auth_publickey` hook end to end (accept, fail-closed reject,
username independence, `auth_none`/`auth_password`/
`auth_openssh_certificate` declines over the wire). Direct-hook rows
against `SessionHandler` (over the wave-1 doubles) drive every
hook with constructible arguments plus `resolve_bridge`. The transport
choice and harness vocabulary remain `w2-loopback-harness`'s to pin —
these rows are its thin precursor, not a competing vehicle.
choice and harness vocabulary remain `w2-loopback-harness`'s to
pin; these rows are its thin precursor, not a competing vehicle.
- **`SessionHandler` visibility widened as `w2-server-mount`'s note
anticipated**: the struct is `pub` inside its still-private module,
re-exported `#[doc(hidden)]` from the crate root — constructible for
the contract tests, hidden from the docs; the documented public API
re-exported `#[doc(hidden)]` from the crate root (constructible for
the contract tests, hidden from the docs); the documented public API
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 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
ecdsa/dsa/rsa features, so no other key type even parses in-tree.
The gate is therefore tested on the bridge core with constructible
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
the gate precedes resolution. The gate checks **both** algorithm
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.
- **`auth_publickey_offered`'s accept-and-delegate is explicit but not
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`
test remain the counting-honesty evidence). The wire rows exercise
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
(`SessionHandler::resolved_identity()`, the fail-closed `None`
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.
- **Certificate fixture added** (`tests/vectors/`): `ca.key`/`ca.pub`
(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`,
principal `git`, validity 2026–2036). Both fingerprints recorded in
`fingerprints.txt`. `alpha-cert.pub` drives the
@@ -139,7 +139,7 @@ Implementation record (decisions made at this task's discretion):
russh's pre-checks).
- **`MemHostKeySource` double added to `tests/common`** (the wave-1
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
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`:
`auth_publickey` is the fingerprint → `resolve_from_fingerprint` →
accept-with-identity / reject fail-closed bridge (non-ed25519 keys
declined *explicitly* by the bridge-core gate before any resolution —
ADR-002 decision 4's explicit-over-inherited pattern), with the
declined *explicitly* by the bridge-core gate before any resolution,
per ADR-002 decision 4's explicit-over-inherited pattern), with the
resolved identity stored in per-connection handler state
(`SessionHandler::resolved_identity()`, read by wave 3);
`auth_publickey_offered` is an explicit accept-and-delegate;
`auth_none`/`auth_password`/`auth_keyboard_interactive`/
`auth_openssh_certificate` are four explicit family-contract declines;
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.
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}`
advertisement surviving, four-username independence, revoke-between-
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
`resolve_bridge` over the wave-1 doubles (all four non-publickey hooks
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
resolved-identity *landing* is code-verifiable only at wave 3 (the
hook's russh-owned argument shape is non-exhaustive and the handler
lives inside `run_stream`) — the store rides the same resolved value
the wire rows exercise.
lives inside `run_stream`), so the store rides the same resolved
value the wire rows exercise.
+25 -22
View File
@@ -1,6 +1,6 @@
---
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
depends_on: [w2-spawn-return]
scope: narrow
@@ -12,11 +12,11 @@ level: implementation
## Description
Enforce the in-crate bounded-resources posture at the handler surface
(`overview.md` §Bounded-resources policy; AGENTS.md convention 9 —
SSH servers are internet-facing; unbounded loops and unbounded buffers
are bugs):
(`overview.md` §Bounded-resources policy; AGENTS.md convention 9:
SSH servers are internet-facing, and unbounded loops and unbounded
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
`Limits::max_concurrent_channels` are declined fail-closed (a clean
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
`inactivity_timeout` always-`Some`, `max_auth_attempts`,
`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
through the harness) and records anything not cheaply observable
as covered by the config-mapping tests.
- **Per-length exec-parse limits stay reserved** for the wave-3
grammar's consumption (`Limits::max_exec_command_len` is carried,
not yet enforced — the grammar owns it).
not yet enforced; the grammar owns it).
The per-identity policy layer (when the alkcall bridge carries it) is
additional and arrives with the bridge itself (wave 3's tunnel leg) —
additional and arrives with the bridge itself (wave 3's tunnel leg),
not from this task.
## Acceptance Criteria
@@ -56,7 +56,7 @@ not from this task.
table)
- docs/architecture/mapping.md §Data-plane / limits
- docs/plans/implementation.md §Wave 2 (the bounded-resources bullet)
- src/config.rs (`Limits` — the budget model and its documented
- src/config.rs (`Limits`, the budget model and its documented
postures)
## Notes
@@ -65,16 +65,18 @@ not from this task.
`src/channel_cap.rs` (`ChannelCap`): the per-session active-channel
count with `admit` (check+increment at accept sites) / `has_room`
(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
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
exists because no shared mutation does. Saturating `release` means
a stray decrement cannot manufacture capacity.
- **Gate ordering** (handler.rs): feature gate first, then the cap.
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
opens would leak against the cap forever (decline-first-alloc-never
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
declines in every build and never consumes a count.
- **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,
so vanilla clients see the vanilla-shaped policy refusal (POC-1 §5).
- **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
open never established one) and consumes the close at most once
(the channel is removed from russh's map before the hook).
- **Recovery observability (russh-pin fact, recorded in the test
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
back, so `Channel::wait()` never delivers the close (the harness's
`await_close` observes only *server-initiated* closes, which is what
the spawn-return/dispatch rows use). The recovery row therefore
rests on wire ordering (deterministic): close and open ride the
client loop's single FIFO event queue, are written in that order,
and the server's packet loop processes them in order — the counted
release precedes the recovery admission; had the release not
and the server's packet loop processes them in order, so the
counted release precedes the recovery admission; had the release not
happened, the recovery open declines and the row fails.
- **`max_auth_attempts` verified behaviorally**: ceiling `2`
(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
`Disconnect::NoMoreAuthMethodsAvailable`, whose client-side shape is
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`
`auth_attempt_ceiling_disconnects_an_exceeding_client`).
- **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
owns); not cheaply behavioral, so recorded, per the task's split.
- **`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.
- **Verification**: `cargo test` (default), and `--features session`
/ `git` / `tunnel` / `--all-features` (the feature-gated cap rows
run in the builds whose opens can be accepted), `cargo clippy
--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
Landed the in-crate concurrent-channel cap at the handler surface:
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
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
writer). Cap-exceeded opens decline fail-closed with
`AdministrativelyProhibited` (`dispatch::cap_exceeded_open_decline`,
@@ -1,6 +1,6 @@
---
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
depends_on: [w2-loopback-harness]
scope: moderate
@@ -12,36 +12,40 @@ level: implementation
## Description
Land the dispatch skeleton and the fail-closed decline core (ADR-006
as amended, ADR-003) — the routing decisions and the declines; the
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
declining stubs this task shapes the routes for.
- **Channel-open gates** (the `channel_open_*` hooks): `session`
channels open only where a session-serving feature exists
(`session` ∨ `git` — the parse path lives inside session channels);
(`session` ∨ `git`, since the parse path lives inside session
channels);
`direct-tcpip` / `forwarded-tcpip` / `direct-streamlocal` gate on
the `tunnel` feature; **`x11` and `auth-agent` always decline**
(never feature-gated — ADR-003 §5); every other/unimplemented
(never feature-gated, per ADR-003 §5); every other/unimplemented
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):
`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` /
`shell_request` route to the session rows. **The git-parseability
probe here is skeleton-level** (service prefix + path shape); the
full grammar (quoted/unquoted paths, service-only →
decline-with-reason, per-length limits) is wave-3's git task, which
replaces this probe. Parsing happens before dispatch and before any
resource allocation — **decline first, alloc never** (a leaked
resource allocation: **decline first, alloc never** (a leaked
channel alloc is a capability leak; the POC-3 stage-A rule).
- **Request-level declines**: `subsystem_request` — the `sftp` name
- **Request-level declines**: `subsystem_request`, where the `sftp`
name
declines-with-reason (the OQ-13 placeholder, folded here server
side); git service names decline-with-reason (a subsystem request
carries only a name, never a repo id); everything else declines.
`env_request` declines unconditionally (OQ-09 — no allowlist ships).
`env_request` declines unconditionally (OQ-09; no allowlist ships).
- **Routing stubs**: the git row and the session rows (pty/shell,
pipe-exec) route to stubs that decline cleanly at the family-seam
boundary — the seams land in wave 3; the stub shape is documented
boundary, since the seams land in wave 3; the stub shape is
documented
as skeleton behavior so the routing decisions are contract-tested
now, not after the mappings exist.
- **Wire decline reasons are shaped here** (the wave-1 error-model
@@ -51,7 +55,7 @@ declining stubs this task shapes the routes for.
hangs).
- **Contract tests through the loopback harness, per feature build**:
the skeleton rows of the decline matrix (`mapping.md` §Decline
matrix) — none-build declines everything; git-build routes
matrix), where none-build declines everything; git-build routes
git-parseable exec (stub at the seam) and declines pty/shell and
plain exec; session-build declines git-parseable exec (the git
parse is its gate) and serves its rows only to the stub; tunnel
@@ -82,7 +86,7 @@ declining stubs this task shapes the routes for.
## References
- 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;
§5 never-features)
- docs/architecture/mapping.md §Session-channel dispatch, §Request-level
@@ -93,10 +97,10 @@ declining stubs this task shapes the routes for.
## 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
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
reason shapers (`unfeatured_open_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
under `session`; `subsystem_request` declines every name (sftp =
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
(ADR-003 §5); the four listen requests decline (the flow is wave
3; never advertise a listen that cannot serve). Decline-first-
alloc-never holds structurally: no hook allocates before its
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
already pinned by the seam stubs).
- **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`
(1) is the honest class: without the feature there is no capability
to distinguish; the tunnel seam stub answers `ConnectFailed` (2),
the explicit reject shape ADR-006 decision 1 pins for failed fabric
opens (accept only after the fabric open succeeds; the skeleton
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.
Within request declines, two shapes: a *capability* decline answers
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
request hook must call `session.channel_failure` itself. The bare
`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
never-feature contract needs but it is an inherited posture flagged
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
open helper (the server opens it toward the client), so its wire
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
(`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
replaces), the wire-reason shapers (`AdministrativelyProhibited`
for unfeatured/never-feature opens, `ConnectFailed` for the tunnel
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`
dispatch, the session-row routes, the request-level declines
(subsystem all-names with the sftp/git named classes, env, x11,
agent), the four listen-request declines, and the named seam
stubs (`git_row_stub`, `pipe_row_stub`, `pty_row_stub`,
`shell_row_stub`, `tunnel_row_stub`) — the wave-3 replacement
points, each documented with its family seam. Module docs now
`shell_row_stub`, `tunnel_row_stub`), which are the wave-3
replacement points, each documented with its family seam. Module docs now
carry the decline-core contract (gates, decline classes, wire
reasons, decline-first-alloc-never, the parse-only posture, the
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:`:
shared rows in every build (x11 open, `tcpip-forward` decline,
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,
tunnel unfeatured); tunnel-only (session opens decline, featured
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).
- **`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),
module doc updated to record the discharge of the
`w2-loopback-harness` note.
+24 -24
View File
@@ -1,6 +1,6 @@
---
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
depends_on: [w2-auth-bridge, w1-test-doubles]
scope: moderate
@@ -12,31 +12,31 @@ level: implementation
## Description
Land the in-process client/server test pair every later contract test
runs through — the decline matrix, every mapping's contract tests
runs through: the decline matrix, every mapping's contract tests
(wave 3), and the client-direction smokes all drive the crate through
this harness. In-memory doubles from wave 1; real-tool smokes ride the
mapping waves (wave 3+) and the e2e wave (wave 5).
- **Server side** — the `SshAdapter` from `w2-server-mount` over an
- **Server side**: the `SshAdapter` from `w2-server-mount` over an
in-process transport. The transport choice (tokio duplex pair vs
127.0.0.1:0 TCP loopback) is pinned here — both work with
127.0.0.1:0 TCP loopback) is pinned here; both work with
`run_stream`/russh's client connect; decide on determinism and
portability and record the choice.
- **Client side** — a russh client driven directly (the wave-4 dial
- **Client side**: a russh client driven directly (the wave-4 dial
surface does not exist yet; the harness builds the russh client
config from the doubles): in-memory key from the ed25519 fixtures
(`FIXTURE_KEYS`), the `MemHostKeyPolicy` double with the permissive
test posture (`with_default(true)` — the accept-all posture exists
test posture (`with_default(true)`; the accept-all posture exists
only as a test choice, never a crate built-in), the keepalive
posture from `Limits`.
- **Harness helpers** — the vocabulary the contract tests speak:
- **Harness helpers** (the vocabulary the contract tests speak):
authenticate with a fixture key (and observe auth failure shapes),
open a channel type (and observe the open-failure reason), send a
session request (and observe success/failure), await exit-status /
close. Helpers stay thin — the russh client API is the vocabulary;
close. Helpers stay thin: the russh client API is the vocabulary;
the harness removes boilerplate, not semantics.
- **Convention** — lives with the wave-1 doubles under `tests/common/`
(or a sibling harness module — same convention, decide and record);
- **Convention**: lives with the wave-1 doubles under `tests/common/`
(or a sibling harness module, same convention, decide and record);
documented as *the* contract-test vehicle for the crate.
The harness's first consumer is its own acceptance test: a full
@@ -74,23 +74,23 @@ the wave-1 doubles work live through the wave-2 stack.
docs):** tokio duplex pair (64 KiB/direction) + russh's
`connect_stream`. russh's real codec/kex/transport run over the pair
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
harness needs. Deterministic, no port, no collision.
- **Placement decision (recorded, same docs):** `tests/common/harness.rs`,
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
`common::harness::drive` rather than re-deriving a mount/connect pair.
- **russh 0.65 behavior surfaced by the harness (a dispatch-core
input):** each un-implemented server `channel_open_*` hook drops its
`ChannelOpenHandle` → the client observes
`ChannelOpenFailure::AdministrativelyProhibited` — the inherited
`ChannelOpenFailure::AdministrativelyProhibited`, the inherited
fail-closed decline on the current skeleton stack. The harness's
authenticated-session row matches the reason generically
(`Error::ChannelOpenFailure(_)`), so
`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.
## Summary
@@ -105,22 +105,22 @@ no-port portability) and **sibling-of-the-doubles placement** under
Contents:
- **`HarnessClientHandler`** — the russh client handler bridging the
- **`HarnessClientHandler`**: the russh client handler bridging the
`MemHostKeyPolicy` double into `check_server_key`, computing the
decision input ([`PresentedHostKey`]) exactly as the wave-4 dial
wiring will: endpoints labeled `LOOPBACK_HOST` / `LOOPBACK_PORT`
(the no-port marker `0`), the fingerprint on the crate's single
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).
- **Server-side assemblers** — `serve_config` / `adapter` from the
- **Server-side assemblers**: `serve_config` / `adapter` from the
wave-1 doubles.
- **`drive` → `LoopbackSession`** — the mount-spawn + connect pair,
- **`drive` → `LoopbackSession`**: the mount-spawn + connect pair,
with clean-disconnect `unmount`.
- **Auth helpers** — `authenticate_publickey` (fixture plumbing
- **Auth helpers**: `authenticate_publickey` (fixture plumbing
removed, `AuthResult` verbatim) and `remaining_methods` (the
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` /
`request_subsystem`, and the message observations: `next_message`,
`await_exit_status`, `await_close`.
@@ -137,10 +137,10 @@ and the `Limits` → client-config posture row.
Live-exercised doubles: `MemIdentityProvider`, `MemHostKeySource`,
`MemHostKeyPolicy` (both verdicts). `TestVault` stays on the custody
rows (fixture keys are unencrypted by contract). The session-request
and exit-status/close helpers are exercised by construction this wave
— channel opens don't confirm on the decline-only skeleton; their live
rows arrive with `w2-dispatch-decline-core`.
and exit-status/close helpers are exercised by construction this
wave, because channel opens don't confirm on the decline-only
skeleton; their live rows arrive with `w2-dispatch-decline-core`.
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.
+30 -26
View File
@@ -1,6 +1,6 @@
---
id: w2-server-mount
name: Wave 2 — SshAdapter::handle mount + russh Config construction
name: "Wave 2: SshAdapter::handle mount + russh Config construction"
status: completed
depends_on: [review-wave-1, w1-config, w1-key-sources, w1-error-model]
scope: moderate
@@ -11,49 +11,51 @@ level: implementation
## 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
`ServeConfig`. This is the substrate every mapping composes into; the
auth bridge and dispatch fill the Handler this task skeletons.
- **`SshAdapter`** — holds the `ServeConfig`; construction
- **`SshAdapter`**: holds the `ServeConfig`; construction
(`SshAdapter::new`) loads the host keys decrypt-at-construction via
the injected `HostKeySource` (ADR-005 decision 6): a locked /
absent / decryption-failed / unknown-version load means **no server
is constructed** — the error surfaces as a distinct adapter
is constructed**, and the error surfaces as a distinct adapter
construction error wrapping the `KeySourceError` variant (never a
half-configured adapter, never a keyless server that fails later).
- **russh `Config` construction** (ADR-002 surface notes) — built from
- **russh `Config` construction** (ADR-002 surface notes), built from
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;
`window_size` / `maximum_packet_size` / `channel_buffer_size` /
`max_auth_attempts` mapped verbatim from `Limits`;
`inactivity_timeout` mapped **always-`Some`** (the bounded-resources
posture — the crate never constructs a session with no wall clock);
posture; the crate never constructs a session with no wall clock);
russh's rekey `Limits` stay russh-enforced at their RFC-4253
defaults (recorded in the wave-1 config notes — keep that
defaults (recorded in the wave-1 config notes; keep that
recording). `server_id` stays russh's default posture unless the
task finds a family reason otherwise (decide and record).
- **The mount** — `SshAdapter::handle(stream)`: a generic stream in,
- **The mount**: `SshAdapter::handle(stream)` with a generic stream
in,
the full russh server stack per stream via `russh::server::run_stream`
(edge termination only — no ALPN code, no relay leg; the `alk/ssh`
pin is a ledger entry, ADR-001 — nothing here speaks it). The exact
(edge termination only, with no ALPN code and no relay leg; the
`alk/ssh` pin is a ledger entry, ADR-001, and nothing here speaks
it). The exact
return shape (awaited `RunningSession`, an internal spawn + join
handle, …) is decided here against the spawn-and-return posture
(ADR-006 decision 4) and the assembly's mounting needs — decide and
(ADR-006 decision 4) and the assembly's mounting needs: decide and
record; it is semver-visible once consumers exist.
- **The Handler skeleton** — the per-connection `russh::server::Handler`
impl with the adapter's `type Error` (`From<russh::Error>`, thiserror
— the wave-2-shaped error surface the wave-1 error-model note
reserved); every hook left at its fail-closed russh default
- **The Handler skeleton**: the per-connection `russh::server::Handler`
impl with the adapter's `type Error` (`From<russh::Error>` via
thiserror, the wave-2-shaped error surface the wave-1 error-model
note reserved); every hook left at its fail-closed russh default
(decline/reject). Later wave-2 tasks fill the auth hooks
(`w2-auth-bridge`) and the channel/request dispatch
(`w2-dispatch-decline-core`).
No mapping seams, no feature-gated serving (wave 3); no client dial
(wave 4). The feature gates stay untouched — nothing consumes them
yet (the dispatch task is their first `cfg` consumer).
(wave 4). The feature gates stay untouched, since nothing consumes
them yet (the dispatch task is their first `cfg` consumer).
## Acceptance Criteria
@@ -80,7 +82,7 @@ yet (the dispatch task is their first `cfg` consumer).
contract: `SshAdapter::handle`, raw vanilla-SSH stream in)
- docs/architecture/decisions/002-russh-pin.md (the Config surface
notes; honest advertisement)
- docs/architecture/decisions/004-auth-dispositions.md (decision 4 —
- docs/architecture/decisions/004-auth-dispositions.md (decision 4:
`methods = {publickey}`)
- docs/architecture/decisions/005-at-rest-key-custody.md
(decrypt-at-construction; locked → no server)
@@ -94,10 +96,10 @@ yet (the dispatch task is their first `cfg` consumer).
Implementation record (decisions made at this task's discretion, the
"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
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;
alkhttp/src/server/adapter.rs spawns it in tests the same way).
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
detached task while still not matching the accept-loop shape any
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.
- **`server_id` stays russh's default** (`SSH-2.0-russh_0.65.0`, the
`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
fingerprints only" governs what the assembly sources. Flagged for
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.
- **Empty key set = `KeySourceError::Absent` even from a custom
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
already documents the empty set as the absent shape.
- **Keepalive stays russh-default on the server direction**
(`None` / 3): the `Limits` keepalive knobs are documented
client-direction posture (reverse-runner evidence); no server-side
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
return shape), so the public API this task exposes is exactly
`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)` +
`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
key-set parity, construction failure matrix (locked / absent / empty /
tampered / wrong-vault / unknown-version / decode, variant preserved),
+32 -29
View File
@@ -1,6 +1,6 @@
---
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
depends_on: [w2-dispatch-decline-core]
scope: narrow
@@ -13,22 +13,23 @@ level: implementation
Land the structural constraint in code (ADR-006 decision 4): russh at
this pin runs server handler callbacks **inline inside the session's
packet loop** — a blocking handler suspends all packet routing for the
packet loop**, so a blocking handler suspends all packet routing
connection and deadlocks against the peer's immediate follow-up bytes
(POC-3 proved by construction with receive-pack). Therefore:
- **Every serving path is `tokio::spawn`ed; handlers return fast.**
The dispatch's routing stubs become spawned serving paths (still
declining stubs — the seams are wave 3), so the pattern is in place
declining stubs, since the seams are wave 3), so the pattern is in
place
before any real mapping hangs on it.
- **Exit/status reporting mechanics** — reporting from spawned tasks
- **Exit/status reporting mechanics**: reporting from spawned tasks
rides the cloned `Session::handle()`: `exit-status` sent **before**
channel close, from the spawned task. The *derivation contract's
rules* (which code → which SSH exit shape) are wave-3
session-mapping material (`mapping.md` rules 1–6); this task lands
the mechanics — the handle clone, the exit-status send, the
ordering-before-close — proven end to end.
- **The proof** — a stub serving path that spawns, completes
the mechanics (the handle clone, the exit-status send, the
ordering-before-close), proven end to end.
- **The proof**: a stub serving path that spawns, completes
asynchronously (after the handler has returned), and reports
`exit-status(N)` + close through the cloned handle; the loopback
harness client observes the exit-status arriving after the handler
@@ -37,7 +38,7 @@ connection and deadlocks against the peer's immediate follow-up bytes
constraint as a test, not a convention.
This is a load-bearing correctness constraint of the whole spine, not
an implementation choice — the wave-3 mappings inherit the pattern
an implementation choice; the wave-3 mappings inherit the pattern
mechanically from the stubs this task leaves behind.
## Acceptance Criteria
@@ -57,7 +58,7 @@ mechanically from the stubs this task leaves behind.
## References
- 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:
spawn-and-return; §exit_status row (sent before close, from the
spawned task)
@@ -68,68 +69,69 @@ mechanically from the stubs this task leaves behind.
## Notes
- `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
ever awaited inline) and `report_exit_status_then_close(&Handle,
channel, N)` (the cloned-handle report with the `mapping.md`
`exit_status` row's ordering baked in: exit-status **before**
close). Both re-exported `#[doc(hidden)]` from the crate root — the
`SessionHandler` §Test-surface pattern — as the contract-test
close). Both are re-exported `#[doc(hidden)]` from the crate root,
the `SessionHandler` §Test-surface pattern, as the contract-test
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
directly. The derivation contract's *rules* (which exit code → which
SSH exit shape) stay wave-3 `mapping.md` material; no rule logic
landed here.
- `src/handler.rs`: the four session-row seam stubs (`git_row_stub`,
`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
decline on the spawned task, and the hooks return without awaiting
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 `+
Send` on handler futures; `Session` is not `Sync`), so the clone is
taken synchronously and only the handle moves into the spawned
future. Wire shapes are unchanged (all `w2-dispatch-decline-core`
rows pass unmodified with the spawned decline); the tunnel seam stub
and every open-level/capability decline stay inline by class — they
answer the open request *before* any channel is owned, so nothing
and every open-level/capability decline stay inline by class, as
they answer the open request *before* any channel is owned, so nothing
spawns yet (documented in the new handler module-doc section §The
spawn-and-return structural constraint; wave 3's
accept-only-after-fabric-open flow is where the tunnel row gains
its spawn).
- `tests/spawn_return.rs` (new): the end-to-end mechanics proof over
the real wire — the russh server stack (duplex transport, harness
client/harness transport posture) mounted over a minimal proof
the real wire, using the russh server stack (duplex transport,
harness client/harness transport posture) mounted over a minimal proof
handler that is *not* the crate's `SessionHandler` (the auth and
dispatch hooks have their own suites; the proof subject is the
mechanics) and that serves execs by composing the exported
plumbing: `spawn_serving_path` of a release-gated serving future
reporting `exit-status(7)` + close through the cloned handle. Two
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
provably returned (a bounded wait on the handler-side timestamp;
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
client's *follow-up traffic* (a second session channel + second
exec) is routed and dispatched normally — POC-3's follow-up-bytes
deadlock shape inverted — and the still-gated no-report state is
exec) is routed and dispatched normally, with POC-3's follow-up-bytes
deadlock shape inverted, and the still-gated no-report state is
asserted before the release.
- `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
the recorded timestamps prove the exit-status left *after* the
handler had already returned (the literal criterion).
- **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
multiple spawned paths); the handler-side timestamps ride
`parking_lot::Mutex`.
- Feature independence: the mechanics are feature-independent (the
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
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
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
code, never awaited inline) and `report_exit_status_then_close`
(exit-status before close through the cloned `Session::handle()`),
both `#[doc(hidden)]` contract-test re-exports; a unit row pins
spawn-returns-while-pending directly.
- **`src/handler.rs`** — the four session-row seam stubs became
spawned serving paths (handle clone taken synchronously — the
- **`src/handler.rs`**: the four session-row seam stubs became
spawned serving paths (handle clone taken synchronously, given the
non-`Send` shape that forcing `async fn` stubs would produce is
coded around and recorded); failure-then-close wire shapes unchanged
(all dispatch-core rows pass unmodified); open-level declines and
the tunnel seam stub stay inline by class, documented; a new
module-doc section carries the constraint and the wave-3
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
under follow-up traffic with the spawn still pending; exit-status
before close, timestamps proving the report outlived the handler's
+58 -52
View File
@@ -1,6 +1,6 @@
---
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
depends_on: [w3-seam-config]
scope: narrow
@@ -13,35 +13,35 @@ level: implementation
Land the wave-2 review gate's booked finding: observability on the
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
today only the routing decision documented per build. The real serving
so far nothing in `src/` emits, so a request-level decline's
"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
matters — so the event **vocabulary** lands first and the mappings
inherit it.
matters; therefore the event **vocabulary** lands first and the
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 /
alkgit / alktunnels / alkcall `src/`: levels, event/field naming,
what gets emitted on error and decline paths) and adopt it — no
alkssh-private dialect; record what was read and chosen.
- **The event vocabulary** — the deciding context on every decline:
row + feature build + channel id (+ the decline class — open-level
vs request-level vs serving-path, and where applicable the failure
shape carried). The mapping tasks then emit at their sites.
- **Instrument what exists now** — the open-level declines
what gets emitted on error and decline paths) and adopt it (no
alkssh-private dialect); record what was read and chosen.
- **The event vocabulary**, with the deciding context on every
decline being row + feature build + channel id (plus the decline
class: open-level vs request-level vs serving-path, and where
applicable the failure shape carried). The mapping tasks then emit at their sites.
- **Instrument what exists now**: the open-level declines
(unfeatured / never-feature / cap-exceeded), the request-level
capability declines (`subsystem`/`env`/`x11`/`agent`, the
unfeatured-parse routes), the seam-stub serving declines, and the
serve-path skeleton (spawned path started / ended, failure) — the
stubs' events carry into the mapping tasks' replaced paths.
serve-path skeleton (spawned path started / ended, failure), with
the stubs' events carrying into the mapping tasks' replaced paths.
- **Safety posture** (conventions 6, OQ-03): no secret material ever
enters an event, never the username; identity/fingerprint exposure
on decline events is decided here against the family posture and
recorded (auth.md: log exposure is deployment policy — the crate's
events must be safe-by-default at every level).
recorded (auth.md: log exposure is deployment policy, and the
crate's events must be safe-by-default at every level).
Small, cross-cutting unit — one pass over the landed handler/dispatch
sites with the vocabulary, so the three mapping branches never land
A single small cross-cutting pass over the landed
handler/dispatch sites with the vocabulary, so the three mapping branches never land
inconsistent events.
## Acceptance Criteria
@@ -54,7 +54,7 @@ inconsistent events.
- [x] The safety posture recorded (no secrets, no username; what
identity detail may appear decided)
- [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`,
`cargo fmt --check`, `cargo doc --no-deps` pass
@@ -66,7 +66,8 @@ inconsistent events.
classes), §Decline matrix
- docs/architecture/auth.md §Dispositions (log exposure posture),
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
@@ -85,8 +86,8 @@ the family's `src/`:
(`listen = …`, `error = %e`, `reason = …`); levels `debug` for
structural observations, `warn` for recoverable failures, `error`
for abnormal pump terminations.
- **alkcall** (`protocol/*`, `channels/*`): the widest use — the same
fields convention (`channel_id`, `request_id`, `error = %e`),
- **alkcall** (`protocol/*`, `channels/*`): the widest use, with the
same fields convention (`channel_id`, `request_id`, `error = %e`),
component-prefixed messages (`"channel client: …"`, `"open wrapper:
…"`), `debug`/`warn` only.
- **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`)
- **`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
order `session, git, tunnel, sftp`), so a captured event is
self-describing per build; `sftp` ships in the string even though it
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
request-level (`"pty_request"`, `"tcpip_forward"`, …), the handler
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
reasons), plus the serving-path class (this wave the seam stub's
decline *is* the serving path; the mappings emit the same class from
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
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),
`"cap-exceeded"`, `"connect-failed"` (tunnel stub / ADR-006
decision 1), `"seam-stub"` (session-row stubs), `"unimplemented"`
(subsystem rows — OQ-13 — and the wave-3 listen forms),
`"declined-by-design"` (env — OQ-09), and on the auth class
(the subsystem rows under OQ-13 and the wave-3 listen forms),
`"declined-by-design"` (env, OQ-09), and on the auth class
`"method-unimplemented"` / `"cert-unsupported"` / `"non-ed25519"` /
`"fingerprint-unresolved"`.
- **`channel_id`** — the wire channel id (u32), on every decline of a
channel (notably: the event's id is *russh's server-side* id —
`ChannelId` is not publicly constructible, so the helpers take the
- **`channel_id`**, the wire channel id (u32), on every decline of a
channel (notably: the event's id is *russh's server-side* id,
because `ChannelId` is not publicly constructible, so the helpers take the
raw u32 via `ChannelId::number()`); the auth class and the global
`-R` listen forms carry none (a separate `decline_global_request`
helper — global requests have no channel).
- **`exec_route`** — only on `exec_request`-row events: the parse
helper, since global requests have no channel).
- **`exec_route`**, only on `exec_request`-row events: the parse
route (`"git"` | `"pipe"`), so a git row's decline is
distinguishable from a pipe row's in combined builds. A `None` route
field is not recorded at all by `tracing` (discovered by the unit
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
`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
structural-observation level. Only [`serving_path_failed`] is `warn`
(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
@@ -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
action, so "started" is the task's honest start, not the hook's spawn
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
(`w3-git-mapping`, `w3-tty-session`, `w3-tunnel-direct`) inherit the
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;
the fingerprint stays out too.** Structurally enforced, not recalled:
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
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
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
logs, never crate-emitted. If a future decline site needs
request-argument detail (e.g. the git grammar's decline reasons in
`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
scanning every captured event: no username string, no `SHA256:`
fingerprint, no `user*`-named field ever appears.
### 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
convention these rows pin: **a hand-rolled minimal
`tracing::Subscriber`** capturing events into a shared buffer,
installed once as the global default (`tests/observe.rs`) — the
server side runs on spawned tasks, so a thread-local
installed once as the global default (`tests/observe.rs`), because
the server side runs on spawned tasks, so a thread-local
`with_default` capture cannot see them, and a global capture needs no
new dependency. Two levels of pinning:
@@ -195,16 +200,17 @@ new dependency. Two levels of pinning:
lifecycle messages, the `warn` failure arm, and the
cfg-derived `feature_build` string.
- Wire-level (`tests/observe.rs`, the loopback harness drives the
real wire; capture installed up front via the mount helpers — a
lazily-installed global loses the earliest emissions): presence by
field-matching at every decline site across the builds — x11 open,
real wire; capture installed up front via the mount helpers, since
a lazily-installed global loses the earliest emissions): presence by
field-matching at every decline site across the builds: x11 open,
`-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
the probe discovered), the unfeatured session/pipe/pty rows, the
git seam-stub's full lifecycle, the tunnel stub's `connect-failed`,
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
`dispatch_core.rs` per-build cfg convention and were verified in
the default, `git`, `session`, `tunnel`, and `--all-features`
+58 -54
View File
@@ -1,6 +1,6 @@
---
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
depends_on: [w3-decline-tracing]
scope: moderate
@@ -11,28 +11,29 @@ level: implementation
## Description
Land the git branch of the spine (ADR-006 decision 2, exec-only) — the
alkforge primary purpose: the full exec grammar replaces the wave-2
Land the git branch of the spine (ADR-006 decision 2, exec-only),
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
published alkgit seam. Everything POC-3 stage A proved, re-landed as
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
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:
what real OpenSSH git sends); unknown services
(`git-send-pack`, `git-upload-archive`) → decline-with-reason, the
honest surface; service-only (no repo) → decline-with-reason —
POC-3 §A: SSH exec must carry the repo; per-length limits consumed
at last — `Limits::max_exec_command_len` enforced **before** any
honest surface; service-only (no repo) → decline-with-reason (POC-3
§A: SSH exec must carry the repo); per-length limits consumed
at last, with `Limits::max_exec_command_len` enforced **before**
any
parse or allocation (decline-first, alloc-never; the wave-2
carried-not-enforced reservation). Decide the wire shapes per
decline class (open/reply vocabulary, whether "unknown service" and
"not git at all" share a reason shape) and record.
- **Repo path → repo_id, no existence oracle**: the parse validates
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
`serve_duplex_resolved`'s `SubstrateError::AccessDenied` posture,
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
alkgit registry contract (what vanilla git sends vs
`validate_repo_id`'s grammar) and record.
- **The dispatch** — the git row becomes real: the taken channel
(the store-and-take handoff lands **here first** — the accepted
- **The dispatch**: the git row becomes real, and the taken channel
(the store-and-take handoff lands **here first**; the accepted
channel is stored at the open, `channels.remove(&id)` →
`into_stream()` in the spawned serving task; the session task
reuses the store) → `alkgit::serve_duplex_resolved(repo_id,
identity, service, duplex, limits, registry, refs, packgen,
ingest, push_options_negotiated, pipeline)` — the serve-config's
`git` seam handle (`w3-seam-config`), the resolved identity riding
ingest, push_options_negotiated, pipeline)`, namely the
serve-config's `git` seam handle (`w3-seam-config`), the resolved identity riding
the `identity` param. Process-mode dialect: alkgit speaks first
(the V0 advertisement, no service prefix) — nothing alkssh-side;
verify the dialect holds over the channel duplex with a byte-level
(the V0 advertisement, no service prefix), and nothing is
alkssh-side. Verify the dialect holds over the channel duplex with a byte-level
row.
- **Exit-status both directions** (POC-3 §A.5): clean serve end →
`exit-status(0)`... decide the code mapping (`SubstrateError`
outcomes → the code — POC evidence: denied → the client observed
`ExitStatus{1}`; vanilla git exits 0 on success) — sent before
outcomes → the code; POC evidence: denied → the client observed
`ExitStatus{1}`; vanilla git exits 0 on success), sent before
close, from the spawned task via the cloned `Session::handle()`
(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)
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
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
the git row through the doubles (clone/push semantics over the
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
(`channel_failure`, channel usable / serving-path failure+close per
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*`
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
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.
## Acceptance Criteria
@@ -106,10 +107,10 @@ contract.
## References
- docs/architecture/mapping.md (the git channel-type row; §Session-
channel dispatch — decline-first-alloc-never; §the structural
constraint)
channel dispatch and its decline-first-alloc-never rule; §the
structural constraint)
- 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
ride), docs/architecture/auth.md §Server direction (step 4)
- docs/research/poc-3-mapping-bakeoff.md §Stage A (the grammar rows,
@@ -126,7 +127,8 @@ contract.
### Decisions recorded (implementation, 2026-10-28)
- **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
for. alkgit's own contract shapes this ("constructed by the
door/adapter from assembler config"), so the budget rides the seam
@@ -135,34 +137,35 @@ contract.
docs.
- **The grammar** (`dispatch.rs`, the probe's rows migrated): exact
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*);
quoted shape is fully `'`-wrapped (unterminated → pipe, the probe's
non-path-shape verdict); leading `/` stripped after quotes; **one
trailing `.git` suffix stripped** (the vanilla-clone idiom; the
alkgit registry grammar excludes `.`, so the door normalizes the
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:
`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
handed through untouched and **alkssh never resolves** (resolution,
authorize, and the collapsed generic denial live at the seam).
Unknown services (`git-send-pack`, `git-upload-archive`) and
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.
- **Wire shapes per decline class** (recorded): SSH request replies
carry no text, so the reason lives only in the event vocabulary;
the git declinables (`unknown-service` / `service-only` /
`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
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:
`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
without a configured seam), plus the unreachable-in-practice
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)
(vanilla git exits 0 on success; the clean-end wire row exercises
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
failed), the machinery-misuse variants defensively 1 (they never
arise from the serving entry). `255` never fabricated (OpenSSH's
could-not-start semantic this door never claims). Status before
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
serve `Err` is *not* a decline event — the substrate already mapped
its own error onto the wire.
serve `Err` is *not* a decline event, as the substrate already
mapped its own error onto the wire.
- **Store-and-take handoff**: `SessionHandler.channels` (plain
`std` HashMap, single-writer — every mutation on the packet loop's
task). Stored at the confirmed open (accept then insert); taken by
`std` HashMap, single-writer, with every mutation on the packet
loop's task). Stored at the confirmed open (accept then insert); taken by
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
wave-3 tasks take them. A routed exec answers `channel_success`
inline (the POC-3 §A.5 shape — the request routed; the serving
outcome reports through exit-status).
inline (the POC-3 §A.5 shape, where the request routed and the
serving outcome reports through exit-status).
- **Identity** (`w2-auth-bridge`'s parked ride-along, discharged):
the resolved identity is cloned into the serving task and passed on
`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
flows; the same repo ungranted → the denial band + exit 1); no
direct identity introspection needed, and the deny row doubles as
the authorize-path witness. `None` can never occur post-auth and
declines serving-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`
requested), no pack; the wire layer applies via
`GitRefs::apply_updates` and writes the report (`unpack ok` /
`ok <ref>`). The duplex discipline *drains the raw tail packless*
— the row had to signal client-side EOF after the report flush
(observed; the real client-eof shape), which is also why vanilla
`ok <ref>`). The duplex discipline *drains the raw tail packless*,
which is why the row had to signal client-side EOF after the report
flush (observed, the real client-eof shape) and also why vanilla
delete-pushes end cleanly. `GitPackIngest::prepare` never fires
packless — asserted.
packless, as asserted.
- **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-alive), docs/stamps updated for the new decision set;
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
to the `seam-unconfigured` reason.
- **Real-tool smoke posture** (recorded): the clone smoke (a real
`git clone` of an unborn-HEAD repo over a TCP-mounted adapter — the
same loopback server stack, the accept loop as the assembly's
`git clone` of an unborn-HEAD repo over a TCP-mounted adapter, on
the same loopback server stack, the accept loop as the assembly's
mount) **requires git ≥ 2.43 and ssh in the environment**;
missing/older tooling is skipped *with a printed reason* —
skipped-with-reason at most, never silently green. The empty-repo
missing/older tooling is skipped *with a printed reason*
(skipped-with-reason at most, never silently green). The empty-repo
clone exercises the full chain (exec parse → dispatch → the seam
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
@@ -233,4 +237,4 @@ contract.
--features git --test git_mapping`, `cargo test --features session`,
`cargo test --all-features`, `cargo clippy --all-targets --all-
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
name: Wave 3 — serve-config seam handles (alktty/alkgit per-feature)
name: "Wave 3: serve-config seam handles (alktty/alkgit per-feature)"
status: completed
depends_on: [review-wave-2, w1-config]
scope: narrow
@@ -13,7 +13,7 @@ level: implementation
Extend [`crate::ServeConfig`] with the per-feature family-seam handles
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
names them inside the seam signatures; the wave-1 config bullet
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
unset field declines its row though the feature is on" note,
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):
- 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);
- the backend **selector** — the `NegotiateRequest.backend` string
- the backend **selector**, the `NegotiateRequest.backend` string
alkssh writes (assembly-supplied, never wire-derived; decide and
record);
- the ownership provider `Option<Arc<dyn
alkcall::core::ownership::OwnershipProvider>>` (an alkcall type —
no new dep; pass-through to `drive_session_pre_negotiated`,
alkcall::core::ownership::OwnershipProvider>>` (an alkcall type,
so no new dep; pass-through to `drive_session_pre_negotiated`,
family ACL layer riding along).
- **The `git` seam handle** (feature-gated), unset → the git rows
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;
false in the v1 default).
- **Manifest** (the wave-1 rule: per-feature sibling deps land with
their first consuming wave — the config fields consume them first,
so they are not dead entries): optional deps
their first consuming wave, and the config fields consume them
first, so they are not dead entries): optional deps
`alktty = "0.8.1"`, `alkgit = "0.1.0"` wired feature-to-dep
(`session = ["dep:alktty"]`, `git = ["dep:alkgit"]`) so a git-build
never compiles alktty and vice versa; verify the pins against
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
≤ our 1.89; MIT OR Apache-2.0). **alktunnels is NOT landed here** —
the config carries no alktunnels types beyond the already-landed
≤ our 1.89; MIT OR Apache-2.0). **alktunnels is NOT landed here**,
since the config carries no alktunnels types beyond the
already-landed
`Arc<ChannelClient>`; its optional dep lands with its first
consuming code (`w3-tunnel-direct`).
@@ -98,7 +99,7 @@ each seam handle carries).
- `Cargo.toml`: optional `alktty = "0.8.1"` /
`alkgit = "0.1.0"` (both `default-features = false`), wired
`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
seam-config is the deps' first consumer; alktunnels still lands with
`w3-tunnel-direct`).
@@ -114,18 +115,20 @@ each seam handle carries).
`GitSeam{registry, refs, packgen, ingest, push_options_negotiated,
pipeline}` held as `cfg`-gated `Option` fields
(`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
is consumed atomically by exactly one seam call
(`drive_session[_pre_negotiated]` / `serve_duplex_resolved`), so a
struct admits no half-supplied seam states (backends without a
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
feature is on" (the decline matrix's config-driven note), checked at
request time by the mappings, never at construction. The already-landed
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
it in the field docs); the fields carry the row vocabulary
(`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,
`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.
- `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
sources under `~/.cargo/registry/src/`): alktty's
`drive_session[_pre_negotiated]` (adapter.rs:205-241) takes
`(writer, reader, [req,] backends: Arc<HashMap<String, Arc<dyn
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`
(duplex.rs:1412) takes the four traits +
`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
`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
alkgit's `^0.28` req; the same crate already in the lockfile) lands as
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
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
`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 session`: zero `alkgit`
occurrences; `--all-features`: both present. Combined with the cfg
gates (all `alktty`/`alkgit` type references live inside
`#[cfg(feature = "session")]` / `#[cfg(feature = "git")]` blocks —
imports, fields, accessors, Debug, constructor, tests), the "a
`#[cfg(feature = "session")]` / `#[cfg(feature = "git")]` blocks (imports, fields, accessors, Debug, constructor, tests), the "a
git-build never compiles alktty and vice versa" invariant is
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
lands the config-level contract rows (constructor posture, `Option`
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
the row the stubs own; the real serving paths make those rows
meaningful in `w3-git-mapping` / `w3-tty-session`, whose seam
+82 -79
View File
@@ -1,6 +1,6 @@
---
id: w3-tty-session
name: Wave 3 — session → alktty (translation bridge + exit derivation)
name: "Wave 3: session → alktty (translation bridge + exit derivation)"
status: completed
depends_on: [w3-git-mapping]
scope: moderate
@@ -11,86 +11,86 @@ level: implementation
## 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
serving path through the published alktty seam, as a **translation
bridge, not a byte pump** (POC-3 §D.1). Everything POC-3 stage D
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)` →
`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
channel — `ssh -t host "cmd"` sends pty-then-exec and must serve
PTY-mode; a `shell_request` without a preceding pty serves
**pipe-mode** `tty = None` — decide whether such a shell serves or
declines and record).
- **Negotiation, built locally, never wire-derived** (OQ-08 — the
channel, since `ssh -t host "cmd"` sends pty-then-exec and must
serve in PTY-mode; a `shell_request` without a preceding pty serves
**pipe-mode** `tty = None`, and whether such a shell serves or
declines is for the task to decide and record).
- **Negotiation, built locally, never wire-derived** (OQ-08: the
serve-config is the whole truth): `pty_request` args →
`TerminalParamsWire{term, cols, rows, pixel_w/h, modes}` (SSH
ttymodes → the reserved modes JSON — alktty OQ-44's reservation,
deferred there); `shell_request` → `NegotiateRequest{carriage:
ttymodes → the reserved modes JSON, which is alktty OQ-44's
reservation, deferred there); `shell_request` → `NegotiateRequest{carriage:
"raw", cmd: shell_argv (the serve-config shell, verbatim), cwd:
session_cwd, env: session_env, backend: the seam selector}` →
**`drive_session_pre_negotiated(writer, reader, req, backends,
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.
**Unset-declines** (OQ-08): `shell_argv` unset → `shell_request`
declines; `exec_wrapper_argv` unset → the pipe row declines — never
a fallback. Decide the wire shape per row (capability decline —
channel stays usable — vs the serving-path failure+close shape) and
declines; `exec_wrapper_argv` unset → the pipe row declines (never
a fallback). Decide the wire shape per row (capability decline,
channel stays usable, vs the serving-path failure+close shape) and
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 →
`Chunk::stdin` (decide the EOF translation — the zero-length
stdin-chunk sentinel vs alktty's `ControlMessage::Eof` CTRL_IN —
`Chunk::stdin` (decide the EOF translation: the zero-length
stdin-chunk sentinel vs alktty's `ControlMessage::Eof` CTRL_IN;
verify what alktty's ChunkReader/backend actually consumes and
record); stdout chunks → SSH `data`; stderr chunks →
`extended_data(1)` (PTY mode merges upstream — the backend's
stderr stream is `None` there, so the translation is universal, no
`extended_data(1)` (PTY mode merges upstream, where the backend's
stderr stream is `None`, so the translation is universal with no
mode-switch); the exit chunk (`STREAM_CTRL_OUT`) → the derivation
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
uppercase-name vocabulary; name accepted verbatim — decide the
decline shape for names alktty's backend semantics reject and
uppercase-name vocabulary, with the name accepted verbatim; decide
the decline shape for names alktty's backend semantics reject and
record). Mechanics: the requests arrive on the packet loop after
the serving task owns the wire — reach them into the spawned bridge
through a per-channel control surface (bounded mpsc — decide +
record; a silent unbounded proxy is a bounded-resources bug).
the serving task owns the wire, so reach them into the spawned
bridge through a per-channel control surface (a bounded mpsc; decide
plus record; a silent unbounded proxy is a bounded-resources bug).
- **The exit derivation contract** (mapping.md rules 1–6,
normative): the alktty exit chunk (`{"type":"exit","code":i32}`,
exit-chunk-is-last) is the sole fidelity input, never widened:
`code ≥ 0` → `exit-status(N)`; negative `−N`, N ≥ 2 →
`exit-signal(name)` via the **server-side Unix signum→name table**
(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);
`−1` → `exit-status(255)`, never a signal claim (rule 3 — the
SIGHUP collision accepted symmetrically, per the mapping.md
`−1` → `exit-status(255)`, never a signal claim (rule 3, the
SIGHUP collision accepted symmetrically per the mapping.md
rationale); non-Unix adds no other negatives (rule 4); the PTY-path
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
**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.
- **Contract rows through the harness, per build** — the wave-2 stub
rows (`pty_row_stub`/`shell_row_stub`/
`pipe_row_stub`) replaced; the decline matrix's unset-field rows —
feature on + handle unset → the rows decline; featured+set serve
through the alkssh-side **TtyBackend shim double** (alktty's
`MockBackend`/`testing.rs` are `pub(crate)` + `cfg(test)` — never
importable; alkssh hand-rolls the shim: a backend that records
`TtyParams`, emits scripted stdout/stderr/exit chunks and consumes
stdin/resizes/signals — per the `TtyHandle` field shapes). The
- **Contract rows through the harness, per build**, with the wave-2
stub rows (`pty_row_stub`/`shell_row_stub`/
`pipe_row_stub`) replaced; the decline matrix's unset-field rows
(feature on + handle unset → the rows decline; featured+set serves
through the alkssh-side **TtyBackend shim double**, since alktty's
`MockBackend`/`testing.rs` are `pub(crate)` + `cfg(test)` and so
never importable; alkssh hand-rolls the shim, a backend that
records `TtyParams`, emits scripted stdout/stderr/exit chunks and
consumes stdin/resizes/signals, per the `TtyHandle` field shapes). The
identity-landing item (the parked wave-2 note) asserted through the
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
`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
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
@@ -112,7 +112,7 @@ proved, re-landed as contract:
per-channel surface)
- [x] The derivation contract rules 1–6 in code with the signum→name
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-
field rows included); identity ride asserted; the `env_request`
decline unchanged; skeleton stub rows migrated
@@ -123,16 +123,16 @@ proved, re-landed as contract:
## References
- 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
(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
- docs/architecture/auth.md §Server direction step 4 (the identity
ride), §Dispositions (OQ-03)
- docs/research/poc-3-mapping-bakeoff.md §Stage D (the bridge shape,
control rows, the signal/exit fidelity findings, §D.3 mechanics)
- docs/research/reverse-runner.md (pipe-mode pid-only signal caveat —
the fidelity note)
- docs/research/reverse-runner.md (the pipe-mode pid-only signal
caveat, the fidelity note)
- alktty 0.8.1: `adapter.rs:234-241` (`drive_session_pre_negotiated`),
`negotiation.rs` (`NegotiateRequest`/`TerminalParamsWire`),
`wire.rs` (chunks, readers/writers), `control.rs` (`ControlMessage`),
@@ -143,31 +143,32 @@ proved, re-landed as contract:
### Decisions recorded (implementation, 2026-10-28)
- **The module** (`src/tty_map.rs`, feature `session`): the session
rows live in one module following the `git_map` pattern — the
bridge, the locally-built negotiation, the derivation contract, and
rows live in one module following the `git_map` pattern, covering
the bridge, the locally-built negotiation, the derivation contract,
and
the serving path. `serde_json` (the OQ-44 modes JSON + the exit
chunk/error-frame reads) and `bytes` (the chunk plane) joined as
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
**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
naively split); cwd/env come from `session_cwd`/`session_env` only.
`backend_params` stays empty (no backend-specific selector fields on
this door; the seam selector is the `backend` string).
**Pipe-less shell decided: a `shell_request` without a preceding
pty SERVES pipe-mode (`tty = None`)** — vanilla sshd serves a shell
without a terminal too (`ssh -T` is legitimate vanilla).
pty SERVES pipe-mode (`tty = None`)**, because vanilla sshd serves
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
task)** for `shell_argv`-unset (`shell-unconfigured`),
`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
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
guards keep the git row's shapes: `channel-not-held` → inline
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
around a duplex pair): SSH `data` → `Chunk::stdin` through a bounded
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
shuts the backend's stdin, identical in effect to
`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**
(decided + recorded): alktty re-runs validation/allocation/ownership
inline even on the pre-negotiated path and answers failures with a
length-prefixed error frame (first byte `0x00` — alktty's ADR-052 §5
disambiguation invariant). The bridge reads it via a 4-byte
length-prefixed error frame (first byte `0x00`, which is alktty's
ADR-052 §5 disambiguation invariant). The bridge reads it via a 4-byte
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
could-not-start semantic (contrast the git row, whose service runs
and honestly reports `1`; `255` is never fabricated for a real
outcome). A bridge read ending **without** an exit chunk reports
nothing (sole-fidelity-input posture: absence is neither 0 nor 255)
— plain close.
nothing (sole-fidelity-input posture: absence is neither 0 nor 255),
and simply closes.
- **The control rows**: `window_change_request` →
`ControlMessage::Resize`, `signal` → `ControlMessage::Signal`,
forwarded through the **per-channel `BridgeSurface`** (handler
state while the row serves): a bounded mpsc whose `try_send` is
never awaited on the packet loop — a full/closed surface drops the
control event (fire-and-forget rows carry no SSH reply to decline
never awaited on the packet loop, so a full/closed surface drops
the control event (fire-and-forget rows carry no SSH reply to decline
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
updates the stored pty params instead (the final size wins —
vanilla sshd's ordering).
updates the stored pty params instead (the final size wins,
matching vanilla sshd's ordering).
- **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
[`SIGNAL_NAMES`] signum→name table (1–31, server-side work,
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
rule-5 fidelity limit documented in the module docs. Report sent
**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).
- **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
teardown (fired by the handler's `Drop`; the russh session ended
however it ended). On either: the serving task **aborts the alktty
session task**, dropping its not-yet-resolved `exit_code` future —
alktty's ADR-056 kill-on-drop contract is the cancelled session's
cleanup — then closes the channel and ends. Witnessed live by the
session task**, dropping its not-yet-resolved `exit_code` future,
letting alktty's ADR-056 kill-on-drop contract clean up the
cancelled session, then closes the channel and ends. Witnessed live by the
shim's kill log row.
- **The contract tests** (`tests/tty_mapping.rs`, session-gated;
`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
stdin/EOF/resizes/signals, emits scripted chunks and exit codes,
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).
- **The identity ride** (the parked wave-2 item, discharged): asserted
through alktty's ADR-050 ownership check — the shim's
`resource_id` guarantees the check evaluates, and the ownership
through alktty's ADR-050 ownership check, with the shim's
`resource_id` guaranteeing the check evaluates, and the ownership
double records the resolved identity; the deny shape also pins the
negotiation-error wire shape.
- **Real-tool smoke posture (recorded)**: one OpenSSH `-tt` pty
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
`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
this composition. The dev-dep `features = ["local"]` unification
applies to the test graph only (the published lib never enables it;
`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 →
exit-status(0) before close); the `stty size`-grade fidelity matrix
is wave 5's.
@@ -273,4 +276,4 @@ proved, re-landed as contract:
`cargo test --features tunnel`, `cargo test --all-features`;
`cargo clippy --all-targets -- -D warnings` ×5 (default / session /
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
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
depends_on: [w3-tty-session]
scope: moderate
@@ -11,71 +11,72 @@ level: implementation
## 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`
(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
from the SSH open arguments (`direct-tcpip`(host, port) →
`SubstrateAddr::HostPort` + `Substrate::Tcp`; `direct-streamlocal`
(socket path) → `SubstrateAddr::Path` + `Substrate::Unix`) and
spawn the flow POC-3 proved: **`TunnelSession::open_direct(&
channel_client, target, substrate)`** → **accept only after the
fabric open succeeds** (accept consumes the handle — the reject
shape is only possible pre-accept) → on fabric failure the explicit
fabric open succeeds** (accept consumes the handle, and the
reject shape is only possible pre-accept) → on fabric failure the explicit
`ConnectFailed` reject (the stub's reason survives as the real
decline; `tunnel_seam_stub_decline` retires into it) → on success
`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
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
to get right (record): the admission is taken at the hook (the
packet-loop single-writer site — no race window against the cap
check) and held across the fabric open; **a fabric failure must
packet-loop single-writer site, with no race window against the
cap check) and held across the fabric open; **a fabric failure must
release the count inside the spawned task** (the
`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
record.
- **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
`AccessControl::check(peer_identity)` gates the mapped opens. The
parked wave-2 `auth_succeeded` item lands here: on auth completion
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::
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
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
`tunnel` compiles; off-Unix a clean open-decline **before any
fabric call** (never a partial bridge); on-Unix the real bridge.
- **Contract rows through the harness, per build** (the wave-2 gate's
booked acceptor vocabulary note is the listen leg's — this task's
rows ride plain client opens): a git/session build with `tunnel`
booked acceptor vocabulary note is the listen leg's, so this
task's rows ride plain client opens): a git/session build with `tunnel`
off declines the tunnel opens (unfeatured, `AdministrativelyProhibited`);
the featured rows: a configured `tunnel` handle (feature + `Arc<
ChannelClient>`) serves, an **absent handle declined with a clean
error even under the feature** (the unchanged unset-declines
posture — the wave-1 config note), the cap rows (cap-gated before
spawn — never a leaked count over a failed bridge: the release
mechanism pinning row), and the `ConnectFailed` row over a refusing
posture, the wave-1 config note), the cap rows (cap-gated before
spawn, never a leaked count over a failed bridge, meaning the
release mechanism pinning row), and the `ConnectFailed` row over a refusing
fabric double. The **fabric double** is the POC-3 §C.4 in-process
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.
- **The optional dep lands here** (`alktunnels = "0.4.1"`, wired
`tunnel = ["dep:alktunnels"]` — first consuming code; crates.io pin
re-verified before committing).
- **The real-tool smoke** — one `ssh -L` (+ curl) through the
`tunnel = ["dep:alktunnels"]` (first consuming code; the crates.io
pin re-verified before committing).
- **The real-tool smoke**: one `ssh -L` (+ curl) through 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
@@ -84,8 +85,8 @@ adapter-side route-1 bridge for `direct-tcpip` and `direct-streamlocal`
explicit `ConnectFailed` reject, the stub retired; the open
hook returns fast
- [x] The cap admission across the fabric window pinned (admit at the
hook, release on fabric failure inside the spawned task — no
leaked count, no double-admit)
hook, release on fabric failure inside the spawned task, with
no leaked count and no double-admit)
- [x] The identity propagates to the channels connection once
(`set_identity` mechanics + hook placement decided and
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-
streamlocal` channel-type rows; §the structural constraint)
- 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,
the OQ-11 runtime-Unix gate)
- 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`,
> `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
`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
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
failure (the admitted-but-rejected shape: ADR-006 decision 1 rejects
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).
Both release sites saturate. Pinning rows:
`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
`the_cap_refuses_beyond_the_ceiling_and_recovers_on_close`
(refusal + close-hook recovery).
- **`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
the peer identity and sets it on its accept-side channel-0
`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
**checked and rejected for this door**, verified against alkcall
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`,
errors "channel client closed"), and the slot is the **shared
assembly handle** (`SshAdapter` hands one `Arc<ServeConfig>` to
every mounted session), so the first session's take would detach
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
at the fabric's serving dispatch is the channels connection's
**dial identity** (CF-005: set once at dial, propagated to channel
0 at `from_connection`) — identity crossing the alkcall wire is not
a protocol concept. Decision: the composition — which identity the
0 at `from_connection`), since identity crossing the alkcall wire
is not a protocol concept. Decision: the composition, meaning which identity the
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);
alkssh structurally refuses anonymous serving (the `no-identity`
open guard, unreachable on the wire — russh authorizes before
channels exist — fail-closed like the git/tty rows) so no
open guard, unreachable on the wire since russh authorizes before
channels exist, fail-closed like the git/tty rows) so no
SSH-shaped exception to the visible-surface invariant exists, and
the harness rows witness the composed gate end to end:
`the_fabric_acl_refuses_an_unscoped_channels_identity` (the
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
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
tunnel rows' dispatch reads it; `auth_succeeded` stays
unimplemented (russh default) — nothing rides it that the store
does not carry. **Upstream ask booked for the wave-3 gate**: a
unimplemented (russh default), since nothing rides it that the
store does not carry. **Upstream ask booked for the wave-3 gate**: a
non-detaching post-construction identity seam on `ChannelClient`
(or per-session fabric legs) would let the door attach per-peer
identities to a shared leg; nothing is silently absorbed.
- **Wire decline classes (recorded)** — two vanilla-observable classes
at the open: *policy refusals* → `AdministrativelyProhibited`
- **Wire decline classes (recorded)**, two vanilla-observable
classes at the open: *policy refusals* → `AdministrativelyProhibited`
(unfeatured, cap-exceeded, the `no-identity` guard) vs
*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
absent fabric handle under the feature (`seam-unconfigured`, the
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
`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
pure-decline path consumes nothing; the admission is taken at the
hook and held across the fabric open; the success release rides the
established channel's close. `forwarded-tcpip` kept its
pre-admit `has_room` cap gate and stub — its `-R` data-leg flow is
`w3-tunnel-listen` material (the harness's russh client cannot
pre-admit `has_room` cap gate and stub, as its `-R` data-leg flow
is `w3-tunnel-listen` material (the harness's russh client cannot
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`):
`ChannelsAdapter` + `InstallChannelZero` + `ChannelOperations` +
`register_tunnel_direct_openable` + `Dispatcher::serve_single_stream`
over a tokio duplex, the connect side the `Arc<ChannelClient>` the
serve-config holds. The hand-rolled dial/dial-fail seam is an
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
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'
`local` feature stays off everywhere — the shipped crate and the
tests).
`local` feature stays off everywhere, in both the shipped crate
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
absent), and it ran green here: one `ssh -N -L` (OpenSSH client)
through a TCP-mounted adapter → the route-1 bridge → the fabric
+44 -44
View File
@@ -1,6 +1,6 @@
---
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
depends_on: [w3-tunnel-direct]
scope: moderate
@@ -12,18 +12,18 @@ level: implementation
## Description
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
task carries the wave-3 gate's biggest open mechanics. Shape per the
family door semantics (vanilla -R: the door's fabric listens, per
arrival the data flows back to the vanilla client, which dials its own
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
the real flow under `tunnel` (+ the streamlocal runtime-Unix gate
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
alktunnels 0.4.1's actual producer/consumer surfaces (`TunnelListener::
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
(alkcall channel-manager openable registration on the door-side
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
this wave's second config course-correction). If the topology does
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
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
a `forwarded-tcpip` channel **toward the vanilla client** with the
SSH message's fields mapped per the vanilla -R convention
(`host_to_connect`/`port` echoing the target args the original
`tcpip_forward` request carried — recorded per pending listen;
originator fields carrying the arrival info — decide and record
`tcpip_forward` request carried, recorded per pending listen;
originator fields carrying the arrival info, decide and record
what `TunnelForwardedParams.peer` yields) → accept-or-reject per
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
(`ForwardedHandler` ownership: the handler owns the pump per
alktunnels' shape; correlation-miss → teardown-at-birth, never a
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` /
`cancel_streamlocal_forward` 1:1 into the family listener's
teardown (`TunnelListener::close` posture); the listener count is a
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
actually-bound number returned through the `&mut u32` (the
family-side listen reply's numbers; the mapping.md/client.md
lifecycle spec).
- **Identity**: the listen dial rides the propagated identity (the
fabric's `AccessControl` gates the listen + forwarded opens the
same as leg 1's — the visible-surface invariant across the whole
listen flow).
- **Contract rows through the harness, per build** — the wave-2 stub
rows replaced: featured + configured serve (the listen flow against
same as leg 1's, giving the visible-surface invariant across the
whole listen flow).
- **Contract rows through the harness, per build**, with the wave-2
stub rows replaced: featured + configured serve (the listen flow against
the fabric double); unfeatured/unset/streamlocal-off-Unix decline
cleanly (honest advertisement: a declined listen never left a half
state); the cancel rows; the arrival → forwarded-tcpip → pump row;
cap rows at the accept point. The **harness grows the client-side
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 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
wave-5 e2e territory explicitly; never silently dropped).
@@ -93,7 +93,7 @@ target):
- [x] The family-ward listen composition decided against alktunnels
0.4.1's actual surfaces and recorded; the door-side inbound-open
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
(target-arg echo per pending listen, cap-gated acceptor, the
pump, teardown-at-birth on correlation-miss)
@@ -112,15 +112,15 @@ target):
- docs/architecture/mapping.md §Channel-type rows (`forwarded-tcpip`
row), §Global-request rows (the `-R` listen flow, the POC-3 note)
- 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)
- 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
client-side acceptor symmetry)
- docs/research/poc-3-mapping-bakeoff.md §Stage C (the recipe;
§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)
- alktunnels 0.4.1: `producer.rs:718-772` (`ForwardedOpener`,
`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
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
(`make_bindfirst_accept_handler` + the `ForwardedOpener` closure)
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
side, the fabric even as the accept side).
- *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
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`
(`TunnelListenSeam`, the wave-3 seam-config's second
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`
over a `ChannelCore` wrapping a clone of the held client's
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
half-configured adapter. A scratch probe (published crates only,
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 vanilla wire bind endpoint rides family-ward as the resource
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`
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
bind endpoint. ADR-001's "resource is not an address" discipline
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
`ListenRouter` (one route serves unboundedly many arrivals;
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
discharged (never-established arrival paths release inside the
arrival pump; established closes release through the packet loop's
`channel_close` as ever) → `Handle::channel_open_forwarded_tcpip`
echoing the listen request's own args: the wire address string
verbatim + the actually-bound port (sshd `c->path` +
`get_local_port` parity — the OpenSSH client matches that exact
string + port and dials its own target), the
`get_local_port` parity, since the OpenSSH client matches that
exact string + port and dials its own target), the
`TunnelForwardedParams.peer` arrival mapped to the originator
fields (HostPort → address/port; Path → path + 0,
degenerate-but-typed) → `pump_bidi(ssh, fabric)` (half-close
semantics: EOF-shutdown in the completed direction; the vanilla
client closes its half when its target socket dies — the row's
stand-in — and the cap release follows through the normal close).
client closes its half when its target socket dies (the row's
stand-in), and the cap release follows through the normal close).
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
`forwarded-streamlocal@openssh.com` with the path echoed (russh's
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
server-initiated direction.
- **Bounded bookkeeping** (convention 9): the per-session listener
map is packet-loop-only (like the `channels`/`ptys` stores); its
bound is a dedicated counter against the same
`max_concurrent_channels` ceiling — a listen is at least one future
channel's potential; the listener/channels mix stays unconstrained
`max_concurrent_channels` ceiling, since a listen is at least one
future channel's potential; the listener/channels mix stays unconstrained
while both stay bounded (no unbound listener map). The route map is
bounded by construction: entries minted exactly at accepted listens,
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
(endpoint, bound-port) pair answers `false` (sshd
cancel-of-unknown parity). Admissions have two sites and releases
three since this task — the cap's recorded single-writer reasoning
updated in `channel_cap.rs` (mutex-guarded, saturating, both).
three since this task, and the cap's recorded single-writer
reasoning updated in `channel_cap.rs` (mutex-guarded, saturating, both).
- **The real-tool smoke**: implemented and green here (tooling
check-gated like the -L smoke's posture): a real `ssh -R
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
client-side accept hook in place (the harness handler's
`server_channel_open_forwarded_tcpip` /
`server_channel_open_forwarded_streamlocal`, acceptor-driven — no
acceptor → clean decline, the family's client-side contract): the
`server_channel_open_forwarded_streamlocal`, acceptor-driven, so
no acceptor means a clean decline, the family's client-side contract): the
wave-2 stub listen rows migrated (`tcpip_forward_decline_emits_*`
split per build: `unfeatured` vs `seam-unconfigured`); the
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
gate on arrivals); the OQ-11 off-Unix rows; the correlation-miss
row; the unvanilla inbound `forwarded-tcpip` open declined
(event-level contract — russh's client has no arbitrary-type open
to drive that row on the wire).
(event-level contract, as russh's client has no arbitrary-type
open to drive that row on the wire).
- **`tests/common` grew**: the forwarding 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
row) as the leg-2 rows' failure-triage vehicle.
- **Verification (all green)**: `cargo test` across default /
@@ -1,6 +1,6 @@
---
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
depends_on: [w4-git-exit-close]
scope: narrow
@@ -14,25 +14,27 @@ level: implementation
Close the wave-3 gate's booked finding item 2
(`review-wave-3` §Booked findings): the **`channel-not-held` guard
lacks wire test rows**. The guard is a documented decline class on
all three rows (git / tty pipe / shell) — a second exec (or request)
on an already-served channel — constructible on the wire, but no row
all three rows (git / tty pipe / shell): a second exec (or request)
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
convention names it contract territory; the fail-closed invariant is
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
than silently widening scope):
- Wire rows through the harness, **per build** (the per-build
cfg-pinned module convention): a second exec on a served git
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
decline-class rows ride the same per-build matrix the dispatch
tests use).
- 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).
- Depends on `w4-git-exit-close` only to keep `tests/git_mapping.rs`
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
rows (git / tty pipe / shell), per build, through the harness
- [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
build)
- [x] `cargo test` (+ per-feature + `--all-features`), clippy ×2
@@ -52,7 +54,7 @@ than silently widening scope):
## References
- 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)
- tests/git_mapping.rs, tests/tty_mapping.rs, tests/dispatch_core.rs
(the per-build matrix convention the rows join), src/observe.rs
@@ -60,39 +62,40 @@ than silently widening scope):
## 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
wire in every build that carries each row).
- **Rows landed** (one per served row, each with the inline-decline
wire shape — `channel_failure` while the first serving path rides
on — plus the channel-stays-usable witness and the tracing event's
wire shape (`channel_failure` while the first serving path rides
on), plus the channel-stays-usable witness and the tracing event's
exact request-decline vocabulary `src/observe.rs` carries):
- `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'
resolve count stays at one). Race-free by construction: the
advertisement drains first and the serve parks at the doubles'
gated `list_refs` behind it, so the wire is quiescent for the
decline.
- `tests/tty_mapping.rs` (cfg `session`), two rows:
the pipe row (second exec on a served pipe channel — event
carries `exec_route = "pipe"`) and the shell row (second shell
request on a served shell channel — the event carries **no**
the pipe row (second exec on a served pipe channel, with the event
carrying `exec_route = "pipe"`) and the shell row (second shell
request on a served shell channel, whose event carries **no**
`exec_route` field, pinning the absent-field shape on the wire).
The exit-`None` shim script keeps the serve live and the wire
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
every build carrying `session` (verified across default / `git` /
`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
convention shared across the mapping binaries; `await_event`
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
`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;
the guard is unreachable there (the reference names the per-build
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
rows: `second_exec_on_a_served_git_channel_declines_channel_not_held`
(`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
stays usable, no second serving path) plus the decline event's exact
vocabulary (class `request`, reason `channel-not-held`, route
`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
observe-suite's capture convention across the mapping binaries.
Verified: full suite + per-feature builds (default, `git`, `session`,
+49 -45
View File
@@ -1,6 +1,6 @@
---
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
depends_on: [review-wave-3]
scope: moderate
@@ -11,49 +11,52 @@ level: implementation
## 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
op row composes into, mirroring `w2-server-mount`'s role for the
server direction. Everything later in the wave rides the authenticated
connection handle this task lands.
- **The dial surface** — `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
`Limits`): the russh client `Config` built from the bounded knobs
(keepalive 15 s / 3 default posture per the reverse-runner evidence —
`client.md` §Correctness facts; inactivity; window/packet knobs per
(keepalive 15 s / 3 default posture per the reverse-runner evidence
and `client.md` §Correctness facts; inactivity; window/packet knobs per
`overview.md`'s table), the dial target as `DialConfig.address`
carries it. If the dial surface needs a `DialConfig` amendment,
land + record it here (the `w3-seam-config` course-correction
pattern) — the wave-1 shape is expected to hold; decide and record.
- **Key source decrypt-at-dial** (ADR-005) — the [`KeySource`] load
pattern), while the wave-1 shape is expected to hold; decide and
record.
- **Key source decrypt-at-dial** (ADR-005), with the [`KeySource`]
load
happens at dial time; locked / absent / decryption-failure /
unknown-`key_version` are all **no-dial outcomes** with their
distinct `KeySourceError` variants surfaced (never env vars, never
plaintext at rest; the adapter-side `Zeroizing` wrap per OQ-07
already landed in wave 1 — consumed here).
- **Host-key policy at `check_server_key`** (OQ-05) — the wave-1
already landed in wave 1, consumed here).
- **Host-key policy at `check_server_key`** (OQ-05), the wave-1
[`HostKeyPolicy`] seam consumed at russh's client hook (the verdict
type was pinned against this hook shape in `w1-host-key-policy`):
the crate default ([`RejectAll`]) connects to nothing; case-1 pins
hard-decline on mismatch; case-2 assembly policies decide. The
crate is policy-blind — no accept-all built-in.
- **The client Handler skeleton at fail-closed defaults** — the
crate is policy-blind, with no accept-all built-in.
- **The client Handler skeleton at fail-closed defaults**: the
russh `client::Handler` impl the dial constructs: server-initiated
channel opens (`server_channel_open_forwarded_tcpip` /
`_forwarded_streamlocal` / `_unknown` / x11 / agent) **declined
cleanly** — never russh's accept-and-drop default (ADR-003 §4, the
cleanly**, never russh's accept-and-drop default (ADR-003 §4, the
client-side symmetry rule; the acceptor registry that serves them
is `w4-reverse-listen`'s surface, this task pins the default).
- **The authenticated connection handle** — the type everything else
- **The authenticated connection handle**, the type everything else
rides: the russh `client::Handle` wrapped with the crate's surface
(publickey-only authentication from the key source — the family
(publickey-only authentication from the key source, the family
contract; no password/keyboard-interactive paths), the policy and
limits carried, the lifecycle primitives' raw material (full
cancellation/clean-close shape is `w4-lifecycle`'s).
- **Test vehicle** — 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
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
vehicle, decide + record its placement and shape. Contract rows:
dial success (authenticated), each key-source failure posture
@@ -62,7 +65,7 @@ connection handle this task lands.
wiring observable, server-initiated open declined cleanly.
- **The smoke posture** decided + recorded (an alkssh-client ↔
alkssh-server in-process loopback is the cheap real-stack smoke;
a real-`sshd` dial is wave-5 e2e territory — never silently
a real-`sshd` dial is wave-5 e2e territory, never silently
dropped).
## Acceptance Criteria
@@ -92,10 +95,11 @@ connection handle this task lands.
surface), docs/architecture/decisions/003-feature-graph.md (§4 the
client-side fail-closed acceptor default this skeleton pins),
docs/architecture/decisions/005-key-sources.md (decrypt-at-dial)
- docs/research/russh.md §Client surface (`client::Handler` inventory
— `check_server_key` defaults to refuse; the
- docs/research/russh.md §Client surface (`client::Handler` inventory,
where `check_server_key` defaults to refuse; the
`server_channel_open_*` hook set; `client::Config` knobs),
docs/research/reverse-runner.md (lessons 3/5 — keepalives
docs/research/reverse-runner.md (lessons 3/5, with the
keepalives
load-bearing, the ephemeral-fleet policy posture)
- docs/research/poc-1-auth-bridge.md §6/§7 (the client-side plumbing
facts), docs/research/poc-2-vault-keys.md (the dial lifecycle)
@@ -106,19 +110,19 @@ connection handle this task lands.
### 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
`Config` → policy at `check_server_key` → single publickey
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),
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
`russh_client_config` mapping (the bounded-config observable).
- `src/client_handler.rs`: the client Handler skeleton (the
mirror of `handler.rs`'s server-direction handler; a separate
module because `w4-op-rows` / `w4-reverse-listen` grow it) —
module because `w4-op-rows` / `w4-reverse-listen` grow it), with
`check_server_key` wired to the injected policy
(certificates fingerprint their inner key; the verdict passes
through), every `server_channel_open_*` hook overridden
@@ -126,16 +130,16 @@ connection handle this task lands.
`ChannelOpenFailure::AdministrativelyProhibited` reject (never
russh's accept-and-drop default), `should_accept_unknown_server_channel`
the explicit `false`, decline events on the crate vocabulary.
- `src/error.rs`: [`DialError`] — the dial direction's closed
- `src/error.rs`: [`DialError`], the dial direction's closed
taxonomy (`KeySource` per-variant preserved / `Address` /
`HostKeyRejected` / `AuthRejected` / `Wire`), with a manual
`From<russh::Error>` that upgrades russh's `UnknownKey` — the only
producer of which is the `check_server_key` decline path — into
`From<russh::Error>` that upgrades russh's `UnknownKey` (the only
producer of which is the `check_server_key` decline path) into
`HostKeyRejected`; hence the `Wire` variant carries no `#[from]`
(one conversion, one place).
- `src/config.rs`: the **DialConfig amendment** (below) + doc
updates; `src/observe.rs`: the client-side decline reasons
(`no-acceptor` — the skeleton's decline-all default; the registry
(`no-acceptor`, the skeleton's decline-all default; the registry
is `w4-reverse-listen`'s) recorded in the vocabulary; `src/lib.rs`:
the re-exports (`dial` / `SshConnection` / `DialError` public; the
handler + config mapping `#[doc(hidden)]` test surface, the
@@ -145,33 +149,33 @@ connection handle this task lands.
### 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
everything `client.md` §The dial surface names, but the
USERAUTH_REQUEST structurally requires a user string
(`authenticate_publickey(user, key)`) and the remote end evaluates
it — 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
`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
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
(the event vocabulary carries no username), and it rides
[`SshConnection`] as carried dial data. Wave-1 records amended
(the config module + field docs).
2. **The server double's placement/shape**
(`tests/common/sshd_double.rs`): a plain russh server (no alkssh
in its serving path — vanilla-parity) on a **real loopback TCP
listener** (`127.0.0.1:0` — a dial target is an address the surface
in its serving path, hence vanilla-parity) on a **real loopback TCP
listener** at `127.0.0.1:0` (a dial target is an address the surface
resolves and connects to; the duplex-pair trick is the
server-direction harness's, and a dial has no `&mut Handle` slot
to attach one to). One dial per instance (doubles are cheap); the
handler resolves fingerprints against an injected accept set (the
vanilla-`authorized_keys` stand-in) with every other auth hook an
explicit reject; the `auth_succeeded` hook hands the row the
per-connection `russh::server::Handle` through a one-shot carrier —
the initiator for the server-initiated-open decline rows (the
per-connection `russh::server::Handle` through a one-shot carrier,
which is the initiator for the server-initiated-open decline rows (the
double calls `channel_open_*` at the client and observes each as
`Err(ChannelOpenFailure(AdministrativelyProhibited))`); a
per-session outcome channel is the clean-close rows' observable;
@@ -180,14 +184,14 @@ connection handle this task lands.
3. **The smoke posture**: an alkssh-client ↔ alkssh-server
in-process loopback over a real TCP socket, landed as the row
`smoke_alkssh_client_dials_the_alkssh_server_direction`
(`tests/client_dial.rs` — 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
real-`sshd` dial is wave-5 e2e territory (`docs/plans/
implementation.md` §Wave 5); nothing is silently dropped.
4. **The decline-class mapping**: russh surfaces a policy decline as
its `UnknownKey` wire error; since the client's `check_server_key`
hook is the only producer of that class, the manual `From` makes
it the semantic [`DialError::HostKeyRejected`] — every other russh
it the semantic [`DialError::HostKeyRejected`], while every other russh
error stays [`DialError::Wire`].
5. **The skeleton's unknown-type gate** (`should_accept_unknown_
server_channel` → explicit `false`) is code-pinned, not
@@ -199,25 +203,25 @@ connection handle this task lands.
wire-row-pinned; the `_unknown` open hook is a defensive reject
behind the gate.
6. **Auth posture**: exactly one `authenticate_publickey` attempt
(ed25519-only, ADR-002 decision 4 — `None` is the whole
(ed25519-only, ADR-002 decision 4, `None` being the whole
hash-algorithm posture); a refusal closes the attempt cleanly
(`Disconnect::ByApplication`) and surfaces
[`DialError::AuthRejected`]. russh's other auth methods
(password / kbi / cert / gss / signer-injection — the signer seam
is dismissed by ADR-005 decision 8) are never reached from
(password / kbi / cert / gss / signer-injection, with the signer
seam dismissed by ADR-005 decision 8) are never reached from
[`SshConnection`].
7. **Bounded posture**: the knob mapping keeps the crate's documented
postures (`keepalive` 15 s / 3, the wall clock always `Some`,
window/packet/buffer verbatim) and leaves
russh's own default postures untouched (rekey `Limits`,
preferred algorithms, `client_id`, `nodelay` — no client-side
preferred algorithms, `client_id`, `nodelay`. No client-side
auth-attempt knob exists at this pin and the single-attempt
contract makes none needed).
### Verification
`cargo test` (default) and `cargo test --all-features` (and
per-feature `--features {session,git,tunnel}`) all green — 14 test
per-feature `--features {session,git,tunnel}`) all green, with 14 test
binaries each, the new `tests/client_dial.rs` carrying 10 rows;
`cargo clippy --all-features --all-targets` and `cargo clippy
--all-targets` both `-D warnings` clean; `cargo fmt --check` clean;
@@ -229,11 +233,11 @@ The client direction's substrate landed: `dial(config) ->
Result<SshConnection, DialError>` runs the fail-closed four-step
sequence (decrypt-at-dial key load with every no-dial posture
distinct; the bounded russh client `Config` from `Limits`; the
injected host-key policy at `check_server_key` — `RejectAll`
injected host-key policy at `check_server_key`, where `RejectAll`
connects to nothing; a single publickey-only auth attempt) and
returns the authenticated connection handle everything later in the
wave rides. One sanctioned `DialConfig` amendment: the required
`username` field (the USERAUTH_REQUEST's user string — no crate
`username` field (the USERAUTH_REQUEST's user string; no crate
default, never identity, never logged). The fail-closed client
Handler skeleton declines every server-initiated open on the wire
(the fake-`sshd` double in `tests/common` pins six hook rows +
+34 -33
View File
@@ -1,6 +1,6 @@
---
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
depends_on: [w3-git-mapping]
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
status-before-close is honored **by scheduling, not by construction**.
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`
*inside* the seam call, before `serve_git_row`'s explicit
`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
the serve tail could order the implicit close first and silently drop
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
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
ordered before any close-contracting drop by construction), or
- 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.
Constraints: every existing git wire row stays green (no behavior
change on the happy path — this is ordering hardening, not semantics
change); the spawn-and-return constraint (ADR-006 decision 4) holds —
the reporting still rides the spawned task; the tty rows are NOT in
change on the happy path, since this is ordering hardening, not
semantics
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
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 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.
## 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
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
(decision 4 — spawn-and-return)
- src/git_map.rs, src/serve.rs (`report_exit_status_then_close` — the
(decision 4, spawn-and-return)
- src/git_map.rs, src/serve.rs (`report_exit_status_then_close`, the
wave-2 reporting mechanics this hardening composes with), alkgit
0.1.0 `serve_duplex_resolved` (the seam whose half-ownership is the
question)
@@ -73,8 +74,8 @@ the by-construction property proven on the wire, not by reasoning.
### The shape decided (2026-10-29)
Went with the first named option — **split the channel halves in
`serve_git_row` and pass them into the seam** — refined by one russh
Went with the first named option, **splitting the channel halves in
`serve_git_row` and passing them into the seam**, refined by one russh
0.65 reality: `russh::channels::io::{ChannelRx, ChannelTx}` are
*private* at this pin, so the split-halves duplex is rebuilt from the
public API in `src/git_map.rs`:
@@ -83,14 +84,14 @@ public API in `src/git_map.rs`:
[`ChannelHalveReader`]: the owned `ChannelReadHalf`
(`channel.split()`), a `poll_read` state machine whose observation
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
re-read (the `_`-arm parity), `Eof` ends the stream, the peer's
`Close` leaves the reader to the channel's sender-death EOF
(`wait()` → `None`). The `wait()` access is *future-owned*: each
read round-trips the half through a boxed future that parks it
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
`Drop` path ever messages the wire**.
- **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.
`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
sender, and its queue position ahead of the explicit close holds by
construction (sequential awaited sends on the one `Handle`),
saturated buffer or not. The hazard's premise (russh's
`ChannelCloseOnDrop` `Drop` spawning a `CHANNEL_CLOSE` send that can
win a queue position on the bounded `Sender<Msg>` —
`event_buffer_size`, default 10 — before the report's) is *removed*,
win a queue position on the bounded `Sender<Msg>`
(`event_buffer_size`, default 10) before the report's, is *removed*,
not raced.
### The wire row (the by-construction property pinned)
`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
`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
answer volume (300 one-pkt-line ref answers) exceeds the entire
outbound pipeline via the new client-direction data gate
(`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
contention, and the row pins: the whole backlogged answer drains
**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
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
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
construction without it.
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
row schedules the serve's stages against prepared wire state) and the
harness's `DataGate` + `drive_with_data_gate` (the `drive*` bodies
@@ -145,15 +146,15 @@ signatures unchanged).
### 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:
no behavior change on the happy path (the wire order the rows pin —
data, exit-status, close — is identical under both shapes when the
race doesn't fire).
no behavior change on the happy path (the wire order the rows pin,
i.e. data, exit-status, close, is identical under both shapes when
the race doesn't fire).
- **Spawn-and-return intact** (ADR-006 decision 4): the reporting
still rides the spawned serving task through the cloned
`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
posture (the adapter docs' recorded default posture preserved); the
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
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` →
`ChannelHalveReader` over the owned read half + the write half's
`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
by-construction property on a genuinely saturated tail (volume flood
→ frozen client loop → duplex → bounded message queue; gate release
→ the tail through the unwinding contention) — and falsifies the old
shape deterministically (15/15 red under a temporary
→ the tail through the unwinding contention), and it also falsifies
the old shape deterministically (15/15 red under a temporary
`into_stream()` revert, 10/10 green with the fix). Doubles gained a
race-safe per-repo serve gate; the harness gained the client-side
data gate + `drive_with_data_gate` (the `drive*` cores factored,
+37 -37
View File
@@ -1,6 +1,6 @@
---
id: w4-lifecycle
name: Wave 4 — lifecycle primitives (cancellable handles + clean disconnect)
name: "Wave 4: lifecycle primitives (cancellable handles + clean disconnect)"
status: completed
depends_on: [w4-op-rows, w4-reverse-listen]
scope: narrow
@@ -13,24 +13,24 @@ level: implementation
Land the OQ-12 resolution in code (`client.md` §Lifecycle
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
`w4-reverse-listen`, the connection handle from `w4-dial`) carries
an explicit cancellation/clean-close path; no handle relies on drop
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
channels, observed clean by the peer (row-pinned through the
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
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,
cancellation, clean close. The docs state the boundary explicitly
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
released); clean disconnect observed by the peer; the
connection-handle cancellation path; the no-policy posture is a
@@ -51,27 +51,27 @@ policy *possible* — and nothing more:
## References
- docs/architecture/client.md §Lifecycle primitives (the OQ-12
resolution), docs/architecture/open-questions.md (OQ-12 — resolved:
primitives only)
- docs/research/reverse-runner.md (fact 4 — reconnect-and-restore is
resolution), docs/architecture/open-questions.md (OQ-12, which
resolved as primitives only)
- docs/research/reverse-runner.md (fact 4, where reconnect-and-restore is
assembly policy, kept in view so the crate never bakes it in)
- docs/research/russh.md §Client surface (`Handle::disconnect`, the
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)
## Notes
### 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
`w4-reverse-listen` block deferred to this task): `reverse_listen` /
`reverse_listen_unix` return a handle carrying the *actually-bound*
endpoint (the `bound_port()` accessor, `None` for the unix shape)
with the explicit `cancel()`; the shared cancel helpers
(`cancel_reverse_tcp` / `cancel_reverse_unix`) — both the handles'
`cancel()` and the stateless wire forms compose into the same
(`cancel_reverse_tcp` / `cancel_reverse_unix`), and both the
handles' `cancel()` and the stateless wire forms compose into the same
paths; the confirmed cancel releases the connection's listener
count exactly once (the repeat is the wire's unknown-listener
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
connection handle's own holders; the stateless forms ride beside
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
one `ConnectionShared` behind the `Arc` (the concurrency-safe
sharing the handles' independent `cancel()`s need; the russh client
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
`Disconnect::ByApplication`), and the module docs' new **§Lifecycle
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
`dropping_the_connection_is_an_abrupt_teardown_close_is_the_only_clean_path`
(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).
- `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`
(drop → the peer's listen still serves a round-tripping data leg;
only the explicit cancel tears down),
`the_listener_handle_cancel_tears_the_forward_down_and_releases_the_budget`
(ceiling → refuse pre-wire; the handle's cancel confirms 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 lands while a data leg is mid-pump; the forward tears down,
the connection stays usable, ends clean); the existing rows
@@ -117,46 +117,46 @@ policy *possible* — and nothing more:
### 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
mirrors the wire's two bind shapes (an actually-bound
address+port / a socket path); the tcp-flavored `bound_port()`
accessor returns `Option` (the unix shape carries no port —
honest, no panic path). A second handle type would have duplicated
accessor returns `Option` (the unix shape carries no port,
honestly with no panic path). A second handle type would have duplicated
the cancel plumbing for a shape the cancel forms already treat as
raw strings.
2. **The abrupt-drop fact (row-pinned, corrected mid-task):** at this
russh pin dropping the *last* wire-stack handle closes the
`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)`
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
(`Disconnect::ByApplication`), row-pinned in both directions
(abrupt-on-drop, clean-on-close) through the server double. No
handle relies on drop timing for *clean* wire state, and the
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).
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()`/
`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()`
semantics would need their own contract; wave-5 review can add it
if an assembly shape needs it.
4. **A live `ReverseListener` keeps the wire stack alive after the
connection handle's drop** — the arc's ownership side effect,
recorded (the alkminer flow's shape: the listener handle outlives
connection handle's drop**, an arc ownership side effect recorded
(the alkminer flow's shape: the listener handle outlives
the connection slot; its `cancel()` composes on the still-live
stack).
### Verification
`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
`cargo clippy --all-features --all-targets -- -D warnings` clean;
`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):
- **`src/client_accept.rs`** — the listener handles: `ReverseListener`
(+ `ReverseEndpoint`) — `reverse_listen` / `reverse_listen_unix`
now return them; the actually-bound endpoint carried, the explicit
- **`src/client_accept.rs`**, the listener handles: `ReverseListener`
(+ `ReverseEndpoint`), which `reverse_listen` / `reverse_listen_unix`
now return; the actually-bound endpoint carried, the explicit
`cancel()` the wire-honest teardown, the listener count released
exactly once per confirmed cancel through the shared cancel paths
the stateless wire forms compose into too; no `Drop` hides a wire
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
primitives-not-policy boundary, the abrupt-drop/clean-close facts);
the public surface unchanged (`close()` is the clean path).
- **`src/lib.rs`** — the handle exports; **`docs/architecture/
client.md`** — the §Lifecycle primitives landing block.
- **Rows** — 3 new tunnel-build rows through the server double
- **`src/lib.rs`** for the handle exports; **`docs/architecture/
client.md`** for the §Lifecycle primitives landing block.
- **Rows**, with 3 new tunnel-build rows through the server double
(`tests/client_reverse_listen.rs`: the drop-handle no-wire-state
row, the handle-cancel + budget-release row, the cancel-mid-data-leg
row) + 1 new every-build row (`tests/client_dial.rs`: the abrupt-
+56 -55
View File
@@ -1,6 +1,6 @@
---
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
depends_on: [w4-dial]
scope: moderate
@@ -11,54 +11,54 @@ level: implementation
## 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
reverse-runner shape: the hub drives every channel open). Type
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
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
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
queue via `wait()`-drain, **not** as `Err` from the request call
(drain until the expected message, `continue` past the rest);
`window_change`/`signal` are fire-and-forget and must be sent
**before** `into_stream()` (the Channel is consumed by conversion);
**never consume the channel queue before `into_stream()`** (a
`wait()` on a fresh channel breaks it — POC-1 §6, both sides). The
**never consume the channel queue before `into_stream()`**, because a
`wait()` on a fresh channel breaks it (POC-1 §6, both sides). The
data pump + the exit consumption mirror the server direction's
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).
- **`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`
resolves to `Err(ChannelOpenFailure)` on server reject (POC-1 §6) —
the typed open, no `wait()` before `into_stream`; **the connection
resolves to `Err(ChannelOpenFailure)` on server reject (POC-1 §6),
giving the typed open with no `wait()` before `into_stream`; **the connection
stays usable after a declined open** (pinned by row). The
`direct-streamlocal` twin with the runtime-Unix gate (OQ-11
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'`),
**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
per-length limits and service-only decline classes ride the shared
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
`subsystem "sftp"` open declines cleanly in every build (the
feature exists, the route doesn't until alkfs lands). The
server-side rows already landed in wave 2; this lands the client
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:
featured + working rows (exec exit-status, pty window-change
ordering, direct-tcpip pump, git exec round trip), decline rows
(server-rejected opens, the sftp decline, off-Unix streamlocal),
the connection-stays-usable row after each decline class. The
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).
## 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
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
mirror the client consumes), docs/architecture/decisions/003-feature-graph.md
(the feature rows), docs/architecture/decisions/006-mapping-bridge-and-dispatch.md
(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
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
semantics consumed), russh 0.65 `client::Handle` (`channel_open_session`
/ `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)
- `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
the crate accessors `russh_handle()` / `budgets()`):
- *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).
- *tunnel*: `tunnel_direct(host, port)` /
`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).
- *git*: `git_service(alkgit::Service, repo_id)` →
`GitPackSession` (raw pack duplex + stderr duplex + the
consumed-exit oneshot).
- *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
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
(`SIGNAL_NAMES` / `ExitReport` / `derive_exit` /
`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
`−signum` code, `−1` for the unmapped/the sentinel collision) and
the module docs carry the client arm's rules as the same
contract's inverse. `tty_map.rs` consumes it (no fork anywhere);
`handler.rs`'s signal row follows the moved path.
- `src/error.rs`: [`OpError`] — the op rows' closed taxonomy
(`OpenDecline(OpenDeclineReason)` — the *family-mirrored* copy of
- `src/error.rs`: [`OpError`], the op rows' closed taxonomy
(`OpenDecline(OpenDeclineReason)`, the *family-mirrored* copy of
the typed open's wire class,
`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) /
`ChannelEnded` (the drain saw the channel's end; nothing
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
gate, pre-wire) / `SftpNotServed` (the placeholder) /
`Wire(russh::Error)` (transparent); the `From<russh::Error>` maps
the typed open's `ChannelOpenFailure` into the family mirror —
one conversion point, unit rows pin every class).
- `src/dispatch.rs`: the shared grammar's **construction arm** —
the typed open's `ChannelOpenFailure` into the family mirror,
with one conversion point, unit rows pin every class).
- `src/dispatch.rs`: the shared grammar's **construction arm**,
`build_git_exec_command(route, repo_id)` beside `parse_exec`, with
unit rows proving the construction round-trips the parse for both
services (the grammar's gate is the `Service` *type*: the
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).
- `src/client.rs`: the crate accessors (`russh_handle`/`budgets`,
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`,
`OpError`/`OpenDeclineReason` public).
- `tests/common/sshd_double.rs`: the fake `sshd` grown with the
op-row script — the session requests' reply knob, the scripted
program (stdout/stderr/exit — `exit-then-close`, the vanilla
op-row script, with the session requests' reply knob, the scripted
program (stdout/stderr/exit in the `exit-then-close` vanilla
discipline; a no-output/no-exit script launches *nothing*, keeping
interactive sessions open), the direct opens' echo-or-reject
behavior, a counted-refusal knob for the
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
(below).
### Decided + recorded
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`
(sentinel/`Resize`/`Signal`/`Eof`) toward the wire; stdout/stderr
chunks + the **in-band exit chunk, last** toward the hub. The
exit ride is the family exit-currency (`ControlMessage::Exit`'s
`i32`): wire `ExitStatus(N)` → `N`, wire `ExitSignal(name)` →
`−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
delivers no exit chunk (the no-exit-chunk close family's mirror).
2. **The exec/pty/git rows never `into_stream()`; the tunnel rows
never wait.** The mechanics facts pin the *shapes*: the
conversion's reader swallows exactly the non-`Data` messages the
exit reports ride on, so an exit-consuming row is structurally a
**split-halves wait-pump** (`Channel::split()` + spawned drain of
`wait()` — the same shape `w4-git-exit-close` pinned server-side;
**split-halves wait-pump** (`Channel::split()` plus a spawned drain
of `wait()`, the same shape `w4-git-exit-close` pinned server-side);
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
respected by *construction* across both row kinds). The tunnel
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
`tty_map::read_bridge_frame`): that plane's chunk types are
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
`0x00`-disambiguation (whose home is the alktty→hub direction,
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.
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
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
the channel — wire-clean, nothing dangling outside the dial's
the channel, wire-clean, nothing dangling outside the dial's
own wall clock.
5. **The launch drains are message-capped** (`DRAIN_MESSAGE_CAP`,
1024): a peer answering only with non-reply messages cannot loop
the drain unboundedly (bounded resources; the drain's idle waits
are bounded by the dial's always-set inactivity timeout). A
refusal closes the channel behind itself (`drain_launch_request`
— the row owned it; never half-serves).
refusal closes the channel behind itself (`drain_launch_request`,
which the row owned; never half-serves).
6. **The pty translation**: family `TerminalParamsWire` → wire
`pty_request` verbatim (term/cols/rows/pixels, `term: None` → 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
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.
7. **The git row's gates before any wire op** (the shared grammar's
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
duplex is the protocol's own byte plane, untouched.
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
assembly's local-listener fact, and the `DialFn` shape carries
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
windows, the bounded duplexes/mpscs, the drain cap.
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
open has none to name (the typed open carries no id back);
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):
the session rows' cheap real-stack smoke is the
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
`smoke_client_exec_row_against_the_server_directions_session_rows`
(gated on `sh` like the wave-3 smokes, skipped-with-reason when
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)
- *every build*: the sftp placeholder's decline (before any wire op,
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
(command rides the wire verbatim; stdout/stderr chunks distinct;
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
`signal`, name verbatim), the exit-signal mirror row (terminal
`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
decline row (`OpenDeclined(Prohibited)`) with the
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
reject-maps-the-class + stays-usable row (`ConnectFailed` mirror;
the streamlocal twin then opens on the same connection on-Unix;
`#[cfg(not(unix))]`'s OQ-11 decline row compiles off-Unix only —
the universal-compile/clean-decline gate mirrored from the server
`#[cfg(not(unix))]`'s OQ-11 decline row compiles off-Unix only, per the
universal-compile/clean-decline gate mirrored from the server
side), the twin's on-Unix pump served in the same row.
- *git*: the grammar's construction riding the wire verbatim + the
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
`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
`--all-features`, the per-build sets: session 12 / git 5 / tunnel 3
/ 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
consumed in-band as the family exit chunk (the derivation contract's
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
duplexes (rejects mirror to `OpenDeclineReason`, the connection
stays usable, row-pinned per decline class); the git row builds the
+50 -46
View File
@@ -1,6 +1,6 @@
---
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
depends_on: [w4-op-rows]
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
**accepts the server-initiated `forwarded-tcpip` opens** and routes
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
`w4-dial` skeleton pins the fail-closed default, this task grows the
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`;
`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`,
`streamlocal_forward` + `cancel_streamlocal_forward` with the
**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).
- **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:
served **only** under a registered acceptor **for that type and
under its feature** (`forwarded-tcpip` / `forwarded-streamlocal`
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
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
originator fields) against the harness's `ForwardedAcceptor`
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
`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
capabilities on/off per connection. An open arriving before its
acceptor installs is declined (fail-closed at arrival time —
decide + record; no buffering of unclaimed opens).
- **The data leg** — a registered acceptor's open: the channel duplex
acceptor installs is declined (fail-closed at arrival time, with a decide-and-record;
no buffering of unclaimed opens).
- **The data leg**, a registered acceptor's open: the channel duplex
handed to the assembly handler (the pump is the acceptor's, per the
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
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 +
registered → served; featured + unregistered → clean decline;
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
real-tool smoke posture decided + recorded (a real `ssh -R`-shaped
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
@@ -72,7 +73,7 @@ registry that serves:
- [x] The acceptor registry lands (shape decided + recorded): served
only under a registered acceptor under `tunnel`;
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
arrival is declined (decided + recorded, no unclaimed buffering)
- [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
(fail-closed client-side), §Lifecycle primitives
- 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
(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
§Client surface (the `server_channel_open_*` hook set; the
`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)
- 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
bookkeeping posture as the template)
@@ -107,36 +108,38 @@ registry that serves:
Implementation decisions (also recorded in `src/client_accept.rs`'s
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
`-R` data-leg types under `tunnel`); one acceptor trait per type
(`TcpipForwardedAcceptor` / `StreamlocalForwardedAcceptor`), the
payload an accepted open hands the assembly: the channel duplex
(family-typed — the `-L` rows' `TunnelChannel`, one type both legs
ride) + the open's echo/originator fields — the harness
`ForwardedAcceptor` vocabulary grown, not forked. Install is
**refuse-never-replace** (`AcceptorHeld` — the server-side
(family-typed, the `-L` rows' `TunnelChannel` that both legs
ride) plus the open's echo/originator fields, the harness
`ForwardedAcceptor` vocabulary grown and not forked. Install is
**refuse-never-replace** (`AcceptorHeld`, the server-side
`ListenRouter::register` posture mirrored); uninstall is explicit
and reports honestly. A new module `src/client_accept.rs` (one
module per file; the rows' method bodies live beside the registry).
- **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;
`OpError::NoAcceptor` pre-wire). The cancel forms do *not* gate on
the acceptor (teardown must work after an uninstall).
- **Bound-port mapping** (recorded wire fact at this pin): russh's
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
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
`Limits::max_concurrent_channels` ceiling (the server-side
listener/ListenerCap posture mirrored): (a) the per-connection
**reverse-listen count** — admitted at the listen rows, released at
confirmed cancels and the refused/wire-error paths (never a map —
the outstanding listens live at the peer); (b) the **accepted-open
handoff count** — admitted in the dial handler's open hook before
**reverse-listen count**, admitted at the listen rows, released
at
confirmed cancels and the refused/wire-error paths (never a map,
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 spawned handoff task; a full counter declines the arrival
(`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*
is platform-neutral byte pumping, hook universal compile).
- **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
task; the import/test are now gated to the exit-consuming builds.
Surprises: russh's client reply shapes (above, the port-0-only mint)
and the `JoinHandle::drop`-is-detach-not-cancel behavior in the
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).
## Summary
@@ -173,49 +177,49 @@ Details in the `Landed` block below.
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
`ForwardedTcpip`/`ForwardedStreamlocal` payloads + `AcceptorHeld`),
the reverse-listen rows (`reverse_listen` / `reverse_listen_cancel`
/ the unix pair, with the gates + dedicated counters), and the
accepted-opens' handoff tasks; exports from the crate root under
`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
(`SshConnection::acceptors` — the live-connection accessor;
(`SshConnection::acceptors`, the live-connection accessor;
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
handoff; unregistered/cap-exhausted → clean `Administratively-
Prohibited` decline with the event vocabulary); every other type
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
(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
`DblListen`; an accept binds a **real** socket, mints the real
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;
cancels match by the raw wire strings, tearing the real listener
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
`ssh -R`-shaped), the port-0 mint, the no-acceptor pre-wire
declines (listen + open, with the event rows), the late-install
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;
cancel recovers), the registry contract through the accessor, and
the streamlocal unix rows (+ the compiled off-Unix gate row).
Per-build matrix: the *unfeatured* builds' wire rows run in every
build through `tests/client_dial.rs`'s decline-all row (the
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
`-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
round-tripped end to end; real-`sshd` `-R` remains wave-5 e2e
(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