feat: channels-path establisher migration (alkcall 0.5.0 / ADR-049) — bump to 0.2.0
Adopt alkcall 0.5.0's channel-open establishment phase (ADR-049 — review 006 E-01 + N-1) and migrate the channels-path semantic failures per its §5 sequencing (alktty ADR-010). - `register_openable` registers `channels/tty/sub` with an establisher (`register_openable_with_establisher`): full `NegotiateRequest` parse of schema-valid `input`, `carriage == "raw"`, non-empty `cmd`, backend lookup, and the ADR-050 ownership check run before the open reply; rejections are `channel:open_failed` with `details.reason` (`unknown_resource` / `handler_error` / `timeout`) — no phantom channel (the SSH contract holds consumer-visibly) - `backend.allocate` deliberately stays in the pump handler: `Establishment` is payloadless so the `TtyHandle` cannot cross the establisher→handler boundary, and re-allocating would violate ADR-005's kill-on-Drop contract — `allocate_failed` remains the one in-band failure class on the channels path (pinned by test) - `TtySessionError::ChannelsOpen` carries alkcall's typed `ChannelOpenError` (`#[from]`) instead of a flattened `String` — the N-1 fix at alktty's layer (breaking) - channels-path semantic failures change shape from `NegotiationRejected` in-band frames to `channel:open_failed` call errors (breaking); the direct-ALPN path is unchanged - `tty_open_spec()` gains a `description` (review 006 E-02) and an ErrorDefinition for `channel:open_failed` (ADR-016 — disclosed via services/schema) - alkcall = "0.5.0"; version 0.2.0; ADR-010 + ADR-009 amendment + tty-adapter.md + CHANGELOG Verification: cargo test (112 lib + integration), cargo test --all-features (136), clippy --all-targets -D warnings (host + wasm), fmt --check, cargo doc --no-deps clean; wasm32-unknown-unknown check confirms the default crate stays wasm-clean.
This commit is contained in:
@@ -194,10 +194,13 @@ implementation agents.
|
|||||||
for the scope gate and optionally consults `OwnershipProvider` for
|
for the scope gate and optionally consults `OwnershipProvider` for
|
||||||
the resource-ownership check (`provider.owns(id_ref, kind, &id,
|
the resource-ownership check (`provider.owns(id_ref, kind, &id,
|
||||||
"tty")` — the 4-arg shape alkcall adopted). The channels path gets
|
"tty")` — the 4-arg shape alkcall adopted). The channels path gets
|
||||||
both for free via `ChannelCore::register_openable`, which wires
|
the scope gate for free via `ChannelCore::register_openable`
|
||||||
`AccessControl` into the operation spec — the registry runs the ACL
|
(which wires `AccessControl` into the operation spec — the
|
||||||
before the wrapper, so the `OpenHandler` only needs to validate
|
registry runs the ACL before the wrapper) and runs the ownership
|
||||||
params and spawn the protocol. Terminal sessions are resources per
|
check in the establisher (ADR-010 — it is part of the
|
||||||
|
establishment phase since alkcall 0.5.0 ADR-049, so a denial is
|
||||||
|
`channel:open_failed`, not an in-band frame; the pump handler
|
||||||
|
only allocates and pumps). Terminal sessions are resources per
|
||||||
ADR-050 (ported as alktty ADR-003); `OwnershipStore::record(&self,
|
ADR-050 (ported as alktty ADR-003); `OwnershipStore::record(&self,
|
||||||
identity, resource_type, resource_id)` is the 3-arg shape (alkcall
|
identity, resource_type, resource_id)` is the 3-arg shape (alkcall
|
||||||
dropped the old `action` arg).
|
dropped the old `action` arg).
|
||||||
@@ -311,5 +314,6 @@ wasm-clean" invariant — run it whenever a non-`local` module changes.
|
|||||||
`StreamError`) come from `alkcall::core`. alkcall is v0.4.x —
|
`StreamError`) come from `alkcall::core`. alkcall is v0.4.x —
|
||||||
breaking changes are expected at this major-zero stage; this is the
|
breaking changes are expected at this major-zero stage; this is the
|
||||||
first real consumer, so we find and fix issues upstream rather than
|
first real consumer, so we find and fix issues upstream rather than
|
||||||
working around them. Pin `alkcall = "0.4.0"` (lockfile resolves
|
working around them. Pin `alkcall = "0.5.0"` (the
|
||||||
0.4.1) and bump deliberately.
|
channel-open establishment phase — ADR-049, review 006 E-01/N-1;
|
||||||
|
adopted by alktty ADR-010) and bump deliberately.
|
||||||
@@ -4,6 +4,70 @@ All notable changes to this crate are documented here. The format is
|
|||||||
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and
|
||||||
this crate adheres to [Semantic Versioning](https://semver.org/).
|
this crate adheres to [Semantic Versioning](https://semver.org/).
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-09-06
|
||||||
|
|
||||||
|
The alkcall 0.5.0 adoption (ADR-049 — review 006 E-01/N-1): the
|
||||||
|
channels-path establishment phase. Breaking on the channels path's
|
||||||
|
error surface; the wire format, the direct-ALPN path, and the
|
||||||
|
`TtyBackend` trait are unchanged.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Channels establishment phase (ADR-010; alkcall 0.5.0 ADR-049 /
|
||||||
|
review 006 E-01).** `register_openable` now registers
|
||||||
|
`channels/tty/sub` with an establisher
|
||||||
|
(`ChannelCore::register_openable_with_establisher`): the semantic
|
||||||
|
validation that used to be post-open in-band error frames — the
|
||||||
|
full `NegotiateRequest` parse of schema-valid `input`, `carriage ==
|
||||||
|
"raw"`, non-empty `cmd`, backend lookup, and the ADR-050 ownership
|
||||||
|
check — runs before the open reply. Rejections resolve the open op
|
||||||
|
with `channel:open_failed` carrying `details: { reason, message }`
|
||||||
|
(`unknown_resource` for an unknown backend, `handler_error` for
|
||||||
|
malformed-negotiation/ownership-denial, `timeout` on the
|
||||||
|
establishment deadline) and no channel ever exists opener-side —
|
||||||
|
the SSH contract. The phantom-channel workaround
|
||||||
|
(allocate → succeed → in-band-fail) is retired.
|
||||||
|
- **`channel:open_failed` ErrorDefinition on `tty_open_spec()`
|
||||||
|
(ADR-016).** The establishment-failure contract (the reachable
|
||||||
|
`details.reason` enum) is disclosed via `services/schema`; the spec
|
||||||
|
also gains a `description` (review 006 E-02) disclosed via
|
||||||
|
`services/list`.
|
||||||
|
- New tests: the establisher unit gates (parse/carriage/cmd/backend/
|
||||||
|
ownership mappings), the end-to-end `channel:open_failed` surfaces
|
||||||
|
(the review-006 verification gate), and a pinned test guarding the
|
||||||
|
one failure class that stays in-band (below).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **BREAKING — `TtySessionError::ChannelsOpen` carries alkcall's
|
||||||
|
typed `ChannelOpenError` (`#[from]`) instead of a flattened
|
||||||
|
`String`** (alkcall 0.5.0 ADR-049 §4 / review 006 N-1, applied at
|
||||||
|
alktty's layer). Consumers branch on
|
||||||
|
`ChannelOpenError::establishment_reason()` instead of parsing a
|
||||||
|
debug string.
|
||||||
|
- **BREAKING — channels-path semantic failures change shape.** An
|
||||||
|
unknown backend (and schema-valid-but-unparseable params,
|
||||||
|
ownership denial) previously surfaced as
|
||||||
|
`TtySessionError::NegotiationRejected` (an in-band `0x00`-prefixed
|
||||||
|
error frame on a channel the open op had already reported as
|
||||||
|
succeeding); they now fail the open op itself as
|
||||||
|
`TtySessionError::ChannelsOpen(CallFailed{ channel:open_failed })`
|
||||||
|
(ADR-010). The one failure class that still arrives in-band is
|
||||||
|
`allocate_failed` (still `NegotiationRejected`): the establisher
|
||||||
|
cannot carry the allocated `TtyHandle` across to the pump handler
|
||||||
|
(`Establishment` is payloadless), and re-allocating would violate
|
||||||
|
ADR-005's kill-on-`Drop` contract — revisit when alkcall gives
|
||||||
|
`Establishment` a payload. The direct-ALPN path's failure surface
|
||||||
|
is unchanged (two transports, two contracts).
|
||||||
|
- `alkcall = "0.5.0"`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **No more phantom channels on the channels path.** Ledger, policy
|
||||||
|
count, and manager state balance on every establishment rejection
|
||||||
|
(the wrapper's teardown runs before the reply), instead of
|
||||||
|
allocating a channel that immediately EOFs with an error frame.
|
||||||
|
|
||||||
## [0.1.0] - 2026-09-05
|
## [0.1.0] - 2026-09-05
|
||||||
|
|
||||||
Initial crates.io release: the `alk/tty` terminal-session protocol —
|
Initial crates.io release: the `alk/tty` terminal-session protocol —
|
||||||
|
|||||||
Generated
+3
-3
@@ -27,9 +27,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "alkcall"
|
name = "alkcall"
|
||||||
version = "0.4.1"
|
version = "0.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9badefe048a194c93eed09bc5326ebf092fc86817e561d88de2cd12b6302753a"
|
checksum = "0bbaeb718c370b74f9136d1d59b4608249bd7d1dd2332c0259f98351d709737f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -48,7 +48,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "alktty"
|
name = "alktty"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alkcall",
|
"alkcall",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "alktty"
|
name = "alktty"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
@@ -25,7 +25,7 @@ default = []
|
|||||||
local = ["dep:portable-pty", "dep:tokio-util", "tokio/process", "tokio/rt-multi-thread"]
|
local = ["dep:portable-pty", "dep:tokio-util", "tokio/process", "tokio/rt-multi-thread"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
alkcall = "0.4.0"
|
alkcall = "0.5.0"
|
||||||
# Minimal, wasm-clean tokio features. `local` adds `process` +
|
# Minimal, wasm-clean tokio features. `local` adds `process` +
|
||||||
# `rt-multi-thread` (non-wasm). Do NOT use `features = ["full"]` — it
|
# `rt-multi-thread` (non-wasm). Do NOT use `features = ["full"]` — it
|
||||||
# pulls in `signal`/`fs`/`net` which break `wasm32-unknown-unknown`.
|
# pulls in `signal`/`fs`/`net` which break `wasm32-unknown-unknown`.
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ Syntax Tree) document for the wire format, and the ADRs.
|
|||||||
## Applicable ADRs
|
## Applicable ADRs
|
||||||
|
|
||||||
Ported from the alknet mono-repo and renumbered into alktty's ADR
|
Ported from the alknet mono-repo and renumbered into alktty's ADR
|
||||||
range (001..009; ADR-009 is alktty-native). The alknet originals at
|
range (001..009; ADR-009 and ADR-010 are alktty-native). The alknet
|
||||||
|
originals at
|
||||||
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain the
|
`/workspace/@alkdev/alknet/docs/architecture/decisions/` remain the
|
||||||
authoritative source for any ADR not yet ported, and for the alknet
|
authoritative source for any ADR not yet ported, and for the alknet
|
||||||
ADRs referenced by alknet number in the docs below (which are not
|
ADRs referenced by alknet number in the docs below (which are not
|
||||||
@@ -35,7 +36,8 @@ tty-specific and therefore not ported into alktty's ADR range).
|
|||||||
| [006](decisions/006-negotiation-framing-self-contained.md) | Self-Contained Negotiation Framing (No alkcall-Internal-Wire-Types Dependency) | alknet ADR-057 | Accepted |
|
| [006](decisions/006-negotiation-framing-self-contained.md) | Self-Contained Negotiation Framing (No alkcall-Internal-Wire-Types Dependency) | alknet ADR-057 | Accepted |
|
||||||
| [007](decisions/007-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | alknet ADR-077 | Accepted (**reversed by ADR-008** — kept for historical context) |
|
| [007](decisions/007-tty-inside-channels.md) | TTY Inside Channels — Sub-Streams, Not Wire Format | alknet ADR-077 | Accepted (**reversed by ADR-008** — kept for historical context) |
|
||||||
| [008](decisions/008-channels-pure-channel-multiplexing.md) | Channels Pure Channel Multiplexing (8-Byte Header, No `stream_type`) | alknet ADR-093 | Accepted (amends alknet ADR-071/074; reverses ADR-007) |
|
| [008](decisions/008-channels-pure-channel-multiplexing.md) | Channels Pure Channel Multiplexing (8-Byte Header, No `stream_type`) | alknet ADR-093 | Accepted (amends alknet ADR-071/074; reverses ADR-007) |
|
||||||
| [009](decisions/009-channels-open-op-is-the-negotiation.md) | The Channels Open Op's `input` Is the Negotiation | alktty-native | Accepted (resolves review #001 L1; amended by review #002 R4 — parse failure is a client-visible error frame) |
|
| [009](decisions/009-channels-open-op-is-the-negotiation.md) | The Channels Open Op's `input` Is the Negotiation | alktty-native | Accepted (resolves review #001 L1; amended by review #002 R4 — parse failure is a client-visible error frame; amended by ADR-010 — semantic failures move into the establisher) |
|
||||||
|
| [010](decisions/010-channels-establisher-migration.md) | Channels-Path Establishment Failures Move into an Establisher | alktty-native | Accepted (adopts alkcall 0.5.0 ADR-049; amends ADR-009's R4 amendment — in-band frames shrink to `allocate_failed`) |
|
||||||
|
|
||||||
## Key Design Principles
|
## Key Design Principles
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ of the schema-validated `input` is now a client-visible
|
|||||||
`malformed_negotiation` error frame, not a silent teardown — see
|
`malformed_negotiation` error frame, not a silent teardown — see
|
||||||
§"Parse-failure error frame (R4 amendment, 2026-09-05)".
|
§"Parse-failure error frame (R4 amendment, 2026-09-05)".
|
||||||
|
|
||||||
|
Amended 2026-09-06 (ADR-010): with alkcall 0.5.0 (ADR-049 — review 006
|
||||||
|
E-01), the semantic-failure classes the R4 amendment reported in-band
|
||||||
|
(malformed negotiation, unknown backend, ownership denial) move into
|
||||||
|
the producer's establisher and resolve as `channel:open_failed` call
|
||||||
|
errors — no phantom channel. The in-band error-frame path on this
|
||||||
|
channel shrinks to one class: `allocate_failed` (the establisher
|
||||||
|
cannot carry the allocated handle across — see ADR-010 §2). The R4
|
||||||
|
tests that asserted the in-band frames for those classes now assert
|
||||||
|
the call-error shape.
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
Before this ADR, the channels path carried the negotiation twice. The
|
Before this ADR, the channels path carried the negotiation twice. The
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
# ADR-010: Channels-Path Establishment Failures Move into an Establisher
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted (2026-09-06). Migrates the channels-path semantic-failure
|
||||||
|
surface per alkcall 0.5.0 / ADR-049 (review 006 E-01 + N-1) — the
|
||||||
|
sequencing ADR-049 §5 names ("alktty migration — channels-path
|
||||||
|
semantic failures move into an establisher; the direct-ALPN in-band
|
||||||
|
error frame is retained — two transports, two contracts").
|
||||||
|
Prerequisites: alkcall 0.5.0 (`ChannelCore::register_openable_with_establisher`,
|
||||||
|
`channel:open_failed`, typed `ChannelOpenError`).
|
||||||
|
|
||||||
|
Amends ADR-009's R4 amendment: the in-band error-frame path on the
|
||||||
|
channels path shrinks to one failure class (`allocate_failed`).
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-009 made the open op's `input` the negotiation and moved semantic
|
||||||
|
validation into the producer-side handler. Because alkcall 0.4.x's
|
||||||
|
open wrapper could not fail after allocation (review 006 E-01), the
|
||||||
|
semantic-failure classes had nowhere to go but the channel stream:
|
||||||
|
the handler wrote a `0x00`-prefixed negotiation error frame
|
||||||
|
(`malformed_negotiation`, `unknown_backend`, `allocate_failed`,
|
||||||
|
ownership-denial `forbidden`) on a channel the open op had already
|
||||||
|
reported as succeeding. The consumer observed success-then-frame —
|
||||||
|
a per-crate workaround ADR-049 explicitly retires:
|
||||||
|
|
||||||
|
> alktty's per-crate in-band error vocabulary is retired on the
|
||||||
|
> channels path; every future ALPN crate gets the establishment reply
|
||||||
|
> for free.
|
||||||
|
|
||||||
|
alkcall 0.5.0 implements the resolution: the wrapper awaits a bounded
|
||||||
|
establisher (`OpenEstablisher` — `Fn(Value, AuthContext) -> BoxFuture<
|
||||||
|
Result<Establishment, EstablishmentError>>`) between channel
|
||||||
|
allocation and the open reply; on rejection it tears the channel down
|
||||||
|
and replies `channel:open_failed` with `details: { reason, message }`
|
||||||
|
(reason ∈ `dial_failed` / `unknown_resource` / `resource_shortage` /
|
||||||
|
`handler_error` / `timeout`). The SSH contract holds
|
||||||
|
consumer-visibly: a failed open never returns a `channel_id`.
|
||||||
|
`ChannelClient::open_channel` carries the `CallError` verbatim in a
|
||||||
|
typed `ChannelOpenError` (review 006 N-1), so the reason is
|
||||||
|
branchable end-to-end.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### 1. The channels path registers an establisher
|
||||||
|
|
||||||
|
`register_openable` (alktty's helper, signature unchanged) now calls
|
||||||
|
`ChannelCore::register_openable_with_establisher` with
|
||||||
|
`make_tty_establisher` — the establishment phase runs the semantic
|
||||||
|
validation that used to be post-open error frames:
|
||||||
|
|
||||||
|
- Full `NegotiateRequest` parse of the registry-schema-validated
|
||||||
|
`input` (the schema stays deliberately partial — the opaque ADR-053
|
||||||
|
backend params pass through; the establisher is the typed gate).
|
||||||
|
- `carriage == "raw"`, non-empty `cmd`.
|
||||||
|
- Backend lookup against the adapter's backend map.
|
||||||
|
- The ADR-050 ownership check (moved from the pump handler's
|
||||||
|
`validate_and_allocate`; the scope gate stays the registry's —
|
||||||
|
pre-allocation, unchanged).
|
||||||
|
|
||||||
|
Rejections map into ADR-049's fixed reason vocabulary (no new codes —
|
||||||
|
the vocabulary is alkcall's one-way wire surface):
|
||||||
|
|
||||||
|
| TTY failure class | `EstablishmentError` variant | `details.reason` |
|
||||||
|
|---|---|---|
|
||||||
|
| `NegotiateRequest` parse failure | `HandlerError` | `handler_error` |
|
||||||
|
| `carriage != "raw"`, empty `cmd` | `HandlerError` | `handler_error` |
|
||||||
|
| unknown backend | `UnknownResource` | `unknown_resource` |
|
||||||
|
| ownership denial | `HandlerError` | `handler_error` |
|
||||||
|
| establishment deadline | (wrapper) | `timeout` |
|
||||||
|
|
||||||
|
`dial_failed` and `resource_shortage` have no TTY producer path (TTY
|
||||||
|
dials nothing at open time; `allocate` capacity failures are the
|
||||||
|
in-band class below) — the spec's `ErrorDefinition` for
|
||||||
|
`channel:open_failed` declares only the three reachable reasons.
|
||||||
|
`HandlerError` covers both the malformed-negotiation semantics (the
|
||||||
|
request shape is wrong) and the ACL-outcome ownership denial (an
|
||||||
|
authorization failure, not a dial failure).
|
||||||
|
|
||||||
|
### 2. Backend allocation stays in the pump handler — `allocate_failed` stays in-band
|
||||||
|
|
||||||
|
The one failure class that cannot migrate: `backend.allocate`. Two
|
||||||
|
reasons, both structural:
|
||||||
|
|
||||||
|
1. **`Establishment` is payloadless** ("reserved for a channel plan").
|
||||||
|
There is no sound way to hand the allocated `TtyHandle` from the
|
||||||
|
establisher to the pump handler: the establisher and handler are
|
||||||
|
separate closures registered once per connection (no per-open key
|
||||||
|
is shared between them), and an `Arc<Mutex<Option<TtyHandle>>>`
|
||||||
|
stash would need exactly that key to be race-free.
|
||||||
|
2. **Double-`allocate` is unsound under ADR-005's kill-on-`Drop`**
|
||||||
|
contract: re-allocating in the handler after an establisher-side
|
||||||
|
allocate would spawn the session target twice (the first handle's
|
||||||
|
kill-guard would fire on its drop — a session killed before it
|
||||||
|
started), and leaking the establisher-side handle would leak the
|
||||||
|
live target.
|
||||||
|
|
||||||
|
So `backend.allocate` runs where it always did — inside
|
||||||
|
`drive_session_pre_negotiated`'s `validate_and_allocate` — and
|
||||||
|
`allocate_failed` remains a post-open `0x00`-prefixed negotiation
|
||||||
|
error frame, surfaced consumer-side as
|
||||||
|
[`TtySessionError::NegotiationRejected`] via the same peek the direct
|
||||||
|
path uses. A pinned test (`allocate_failure_still_arrives_in_band_on_
|
||||||
|
channels_path`) guards this boundary. Revisit when alkcall gives
|
||||||
|
`Establishment` a payload (a channel plan carrying the handle would
|
||||||
|
let `allocate_failed` join the call-error surface).
|
||||||
|
|
||||||
|
The pump handler's parse arm (`malformed_negotiation` frame) and
|
||||||
|
`validate_and_allocate`'s other error frames are now defense-in-depth:
|
||||||
|
the establisher rejects those classes first, so on the registered
|
||||||
|
path only `allocate_failed` is reachable. The arms stay (a
|
||||||
|
no-establisher registration still compiles against alkcall's API and
|
||||||
|
must not silently EOF), and the R4 seam test pins the handler-side
|
||||||
|
frame.
|
||||||
|
|
||||||
|
### 3. The direct-ALPN path is unchanged
|
||||||
|
|
||||||
|
`TtyAdapter::handle` / `drive_session` keep the wire-frame
|
||||||
|
negotiation (ADR-001) and the full in-band error-frame vocabulary
|
||||||
|
(`unknown_backend`, `malformed_negotiation`, `allocate_failed`,
|
||||||
|
`forbidden`). Two transports, two contracts — the direct path has no
|
||||||
|
open op to fail, and its `0x00`-peek disambiguation is the ADR-001
|
||||||
|
wire-stable contract. The `enforce_scope` split from ADR-009 is
|
||||||
|
unchanged (`true` direct, `false` channels — the registry's
|
||||||
|
`AccessControl` is the channels scope gate).
|
||||||
|
|
||||||
|
### 4. Spec enrichment (review 006 E-02 + ADR-016)
|
||||||
|
|
||||||
|
`tty_open_spec()` gains:
|
||||||
|
|
||||||
|
- `description` — disclosed by `services/list` (the open op describes
|
||||||
|
itself; the produced resource set stays OQ-40's deferred
|
||||||
|
`channel/resources/subscribe` shape).
|
||||||
|
- An `ErrorDefinition` for `channel:open_failed` with the
|
||||||
|
`details.reason` enum — so `services/schema` discloses the
|
||||||
|
establishment-failure contract per ADR-016 (ADR-049 §3: "ALPN
|
||||||
|
crates' open-op specs gain matching `ErrorDefinition` entries so
|
||||||
|
`services/schema` discloses the failure contract").
|
||||||
|
|
||||||
|
### 5. Consumer surface
|
||||||
|
|
||||||
|
`TtySession::open_via_channels` failures after this ADR:
|
||||||
|
|
||||||
|
| Failure | Where | Consumer-visible as |
|
||||||
|
|---|---|---|
|
||||||
|
| schema-invalid params | registry gate | `ChannelsOpen(CallFailed{ INVALID_INPUT })` |
|
||||||
|
| unparseable params (local fail-fast) | pre-open local parse | `InvalidParams` |
|
||||||
|
| malformed negotiation (schema-valid) | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: handler_error })` |
|
||||||
|
| unknown backend | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: unknown_resource })` |
|
||||||
|
| ownership denial | establisher | `ChannelsOpen(CallFailed{ channel:open_failed, reason: handler_error })` |
|
||||||
|
| establishment timeout | wrapper bound | `ChannelsOpen(CallFailed{ channel:open_failed, reason: timeout })` |
|
||||||
|
| ACL denial (scope) | registry gate | `ChannelsOpen(CallFailed{ FORBIDDEN })` |
|
||||||
|
| channel cap | wrapper `check_open` | `ChannelsOpen(CallFailed{ channel:too_many_channels })` |
|
||||||
|
| **allocate failure** | pump handler, post-open | **`NegotiationRejected{ "allocate_failed" }`** |
|
||||||
|
|
||||||
|
`TtySessionError::ChannelsOpen` now carries alkcall's typed
|
||||||
|
`ChannelOpenError` verbatim (`#[from]`) instead of a flattened
|
||||||
|
`String` — the N-1 fix applied at alktty's layer, so consumers branch
|
||||||
|
on `establishment_reason()` without unwrapping strings.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
|
||||||
|
- The phantom-channel workaround is retired: a semantically invalid
|
||||||
|
open no longer allocates → succeeds → in-band-fails. Ledger, policy
|
||||||
|
count, and manager state balance on every rejection.
|
||||||
|
- Retry policy / UX can branch: `unknown_resource` (bad config — don't
|
||||||
|
retry), `timeout` (maybe retry), `allocate_failed` (capacity —
|
||||||
|
still distinguishable in-band).
|
||||||
|
- The consumer's `open_via_channels` failure is a typed call error —
|
||||||
|
no peeking at the data stream for anything but `allocate_failed`.
|
||||||
|
|
||||||
|
**Negative:**
|
||||||
|
|
||||||
|
- **Breaking (0.2.0):** channels-path semantic failures change shape
|
||||||
|
(`NegotiationRejected{unknown_backend}` → `ChannelsOpen(CallFailed{
|
||||||
|
channel:open_failed})`), and `ChannelsOpen`'s payload changed. The
|
||||||
|
direct path's failure surface is unchanged.
|
||||||
|
- The `channel:open_failed` reason codes the establisher emits join
|
||||||
|
the wire-stable error set (ADR-049's one-way door).
|
||||||
|
- `allocate_failed` remains a second, in-band failure shape on the
|
||||||
|
channels path — one residual asymmetry, pinned and documented until
|
||||||
|
`Establishment` gains a payload.
|
||||||
|
|
||||||
|
## Door type
|
||||||
|
|
||||||
|
**One-way (wire-visible).** The establisher mapping fixes
|
||||||
|
`details.reason` values for TTY's channels path — consumers branch on
|
||||||
|
`unknown_resource` / `handler_error` / `timeout`, so re-mapping a
|
||||||
|
failure class between reasons is a peer-breaking change. The
|
||||||
|
establisher's internal validation order is a two-way door
|
||||||
|
(implementation detail behind the fixed surface). ADR-009's
|
||||||
|
"two-way for now" negotiation-location decision is unaffected — this
|
||||||
|
ADR moves failure *reporting*, not the negotiation *location*.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- alkcall ADR-049 (the establisher, `channel:open_failed`, the typed
|
||||||
|
client error — the upstream capability this ADR adopts)
|
||||||
|
- alkcall review 006 (E-01 the establishment gap; N-1 the typed
|
||||||
|
client error; E-02 the discovery enrichment this ADR's spec
|
||||||
|
enrichment adopts)
|
||||||
|
- ADR-009 (the open op's `input` is the negotiation; the R4
|
||||||
|
amendment's in-band error frames — shrunk to `allocate_failed` by
|
||||||
|
this ADR)
|
||||||
|
- ADR-005 (kill-on-`Drop` — why establisher-side allocation is
|
||||||
|
unsound)
|
||||||
|
- ADR-001 (the §5 `0x00` disambiguation — retained for the direct
|
||||||
|
path and the channels `allocate_failed` frame)
|
||||||
|
- ADR-002 (backend selection/allocation ownership)
|
||||||
|
- `src/channels.rs` — `make_tty_establisher`, `make_tty_open_handler`,
|
||||||
|
`tty_open_spec`
|
||||||
|
- `src/session.rs` — `TtySessionError::ChannelsOpen`, `open_via_channels`
|
||||||
|
- `src/testing.rs` — the pinned `allocate_failed`-stays-in-band test
|
||||||
@@ -65,11 +65,13 @@ channels path reuses — per [ADR-008](decisions/008-channels-pure-channel-multi
|
|||||||
TTY always uses its 5-byte format, so the same driver runs in
|
TTY always uses its 5-byte format, so the same driver runs in
|
||||||
both direct `alk/tty` and channels `alk/channels` modes; only the
|
both direct `alk/tty` and channels `alk/channels` modes; only the
|
||||||
`BiStream` source differs. The two paths differ in where the
|
`BiStream` source differs. The two paths differ in where the
|
||||||
`NegotiateRequest` comes from: the direct path reads the wire-frame
|
`NegotiateRequest` comes from and where semantic failures go: the
|
||||||
negotiation (ADR-001 §"Negotiation Frame"); the channels path parses
|
direct path reads the wire-frame negotiation (ADR-001 §"Negotiation
|
||||||
the open op's registry-validated `input` and runs
|
Frame") and answers failures with in-band error frames; the channels
|
||||||
`drive_session_pre_negotiated` (ADR-009 — the channels path carries no
|
path parses the open op's registry-validated `input` and runs
|
||||||
second negotiation frame on the channel's data stream).
|
`drive_session_pre_negotiated` (ADR-009 — no second negotiation
|
||||||
|
frame), with the establisher rejecting the semantic-failure classes
|
||||||
|
as `channel:open_failed` before the handler spawns (ADR-010).
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
@@ -172,11 +174,20 @@ entering raw mode. The error response shape:
|
|||||||
{ "error": "unknown_backend", "backend": "kubernetes" }
|
{ "error": "unknown_backend", "backend": "kubernetes" }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
On the **direct-ALPN path** this is the failure surface for every
|
||||||
|
negotiation/allocation failure class (below). On the **channels path**
|
||||||
|
the semantic classes (unknown backend, malformed negotiation,
|
||||||
|
ownership denial) are rejected by the establisher before the open
|
||||||
|
reply and surface as `channel:open_failed` call errors (alkcall 0.5.0
|
||||||
|
ADR-049 / alktty ADR-010) — the in-band frames on that path are
|
||||||
|
defense-in-depth arms plus the one non-migratable class
|
||||||
|
(`allocate_failed`, ADR-010 §2).
|
||||||
|
|
||||||
| Error | When | Shape |
|
| Error | When | Shape |
|
||||||
|-------|------|------|
|
|-------|------|------|
|
||||||
| `unknown_backend` | the `backend` string is not in the adapter's backend map | `{"error":"unknown_backend","backend":"..."}` |
|
| `unknown_backend` | the `backend` string is not in the adapter's backend map — direct path, or the channels path's defense-in-depth arm (the establisher rejects it as `channel:open_failed` / `unknown_resource` first) | `{"error":"unknown_backend","backend":"..."}` |
|
||||||
| `malformed_negotiation` | the negotiation frame failed to parse as JSON or failed `NegotiateRequest` validation — on the direct path the wire frame, on the channels path the open op's `input` (schema-valid values can still fail the typed parse, e.g. `cwd` typed as a number, because the schema is deliberately partial) | `{"error":"malformed_negotiation","message":"..."}` |
|
| `malformed_negotiation` | the negotiation frame failed to parse as JSON or failed `NegotiateRequest` validation — on the direct path the wire frame; on the channels path the open op's `input` (schema-valid values can still fail the typed parse, e.g. `cwd` typed as a number, because the schema is deliberately partial — the establisher rejects it as `channel:open_failed` / `handler_error` first; the handler-side frame is defense-in-depth, ADR-010 §2) | `{"error":"malformed_negotiation","message":"..."}` |
|
||||||
| `allocate_failed` | `backend.allocate()` returned a `TtyError` | `{"error":"allocate_failed","message":"..."}` |
|
| `allocate_failed` | `backend.allocate()` returned a `TtyError` — on both paths: the establisher cannot carry the allocated handle across to the pump handler (ADR-010 §2), so allocation failure is the one in-band failure class on the channels path | `{"error":"allocate_failed","message":"..."}` |
|
||||||
|
|
||||||
After sending the error response, the adapter closes the write half of
|
After sending the error response, the adapter closes the write half of
|
||||||
the bidi stream. The client reads the error frame and treats stream close
|
the bidi stream. The client reads the error frame and treats stream close
|
||||||
@@ -358,6 +369,7 @@ architectural commitment.
|
|||||||
| Backend cleanup on session cancel | [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md) | Dropping `exit_code` future kills the session target; the adapter triggers it by dropping the `TtyHandle` on cancel |
|
| Backend cleanup on session cancel | [ADR-005](decisions/005-backend-cleanup-on-session-cancel.md) | Dropping `exit_code` future kills the session target; the adapter triggers it by dropping the `TtyHandle` on cancel |
|
||||||
| Channels pure channel multiplexing | [ADR-008](decisions/008-channels-pure-channel-multiplexing.md) | The same session driver runs in both direct and channels modes; only the `BiStream` source differs |
|
| Channels pure channel multiplexing | [ADR-008](decisions/008-channels-pure-channel-multiplexing.md) | The same session driver runs in both direct and channels modes; only the `BiStream` source differs |
|
||||||
| Negotiation carried in the open op | [ADR-009](decisions/009-channels-open-op-is-the-negotiation.md) | The channels path carries no second negotiation frame; the open op's registry-validated `input` is the negotiation (`drive_session_pre_negotiated`) |
|
| Negotiation carried in the open op | [ADR-009](decisions/009-channels-open-op-is-the-negotiation.md) | The channels path carries no second negotiation frame; the open op's registry-validated `input` is the negotiation (`drive_session_pre_negotiated`) |
|
||||||
|
| Channels establishment failures are call errors | [ADR-010](decisions/010-channels-establisher-migration.md) | The channels path's semantic failures are rejected by the establisher (`channel:open_failed`, alkcall ADR-049); `allocate_failed` stays in-band |
|
||||||
| Dynamic resource ownership | alknet ADR-050 | Terminal sessions as runtime-spawned resources; the adapter's access-control shape |
|
| Dynamic resource ownership | alknet ADR-050 | Terminal sessions as runtime-spawned resources; the adapter's access-control shape |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
@@ -379,6 +391,11 @@ architectural commitment.
|
|||||||
the same session driver runs in channels mode
|
the same session driver runs in channels mode
|
||||||
- [ADR-009](decisions/009-channels-open-op-is-the-negotiation.md) — the
|
- [ADR-009](decisions/009-channels-open-op-is-the-negotiation.md) — the
|
||||||
channels path carries no second negotiation frame (the open op's
|
channels path carries no second negotiation frame (the open op's
|
||||||
|
registry-validated `input` is the negotiation)
|
||||||
|
- [ADR-010](decisions/010-channels-establisher-migration.md) — the
|
||||||
|
channels path's semantic failures are establisher call errors
|
||||||
|
(`channel:open_failed`); the in-band frames shrink to
|
||||||
|
`allocate_failed`
|
||||||
`input` is the negotiation)
|
`input` is the negotiation)
|
||||||
- alknet ADR-050 — the ownership model the adapter's access control
|
- alknet ADR-050 — the ownership model the adapter's access control
|
||||||
declares against
|
declares against
|
||||||
|
|||||||
+373
-62
@@ -1,5 +1,6 @@
|
|||||||
//! Producer half of the channels integration — the `register_openable`
|
//! Producer half of the channels integration — the `register_openable`
|
||||||
//! helper and the `TtyOpenHandler` factory (ADR-047 §3).
|
//! helper and the `TtyOpenHandler` factory (ADR-047 §3, amended by
|
||||||
|
//! alkcall ADR-049 / alktty ADR-010 with an establishment phase).
|
||||||
//!
|
//!
|
||||||
//! This is the path where TTY rides inside `alk/channels` rather than
|
//! This is the path where TTY rides inside `alk/channels` rather than
|
||||||
//! directly on a `alk/tty` ALPN connection. The assembly layer (an
|
//! directly on a `alk/tty` ALPN connection. The assembly layer (an
|
||||||
@@ -9,20 +10,27 @@
|
|||||||
//! on the per-connection overlay registry. When a consumer (a
|
//! on the per-connection overlay registry. When a consumer (a
|
||||||
//! [`crate::session::TtySession`] or any `ChannelClient` caller)
|
//! [`crate::session::TtySession`] or any `ChannelClient` caller)
|
||||||
//! invokes `channels/tty/sub`, the alkcall channels wrapper does
|
//! invokes `channels/tty/sub`, the alkcall channels wrapper does
|
||||||
//! `check_open` → `open_channel` → spawn the `TtyOpenHandler` →
|
//! `check_open` → `open_channel` → **await the establisher bounded**
|
||||||
//! respond with `{ channel_id }`. The `TtyOpenHandler` receives the
|
//! (alkcall ADR-049 §1) → spawn the `TtyOpenHandler` → respond with
|
||||||
//! channel's `Connection` (data-plane ALPN `alk/tty`), calls
|
//! `{ channel_id }`. The establisher performs the semantic validation
|
||||||
//! `accept_bi()` to get the channel's `BiStream`, and runs
|
//! (full `NegotiateRequest` parse, `carriage == "raw"`, backend
|
||||||
//! [`crate::adapter::drive_session_pre_negotiated`] on it — the same
|
//! lookup, ADR-050 ownership check) and fails the open op with
|
||||||
//! three-pump session driver as the direct-ALPN path, minus the
|
//! `channel:open_failed` on rejection — no phantom channel. The
|
||||||
//! wire-frame negotiation phase (the open op's `input` is the
|
//! `TtyOpenHandler` receives the channel's `Connection` (data-plane
|
||||||
//! negotiation, ADR-009).
|
//! ALPN `alk/tty`), calls `accept_bi()` to get the channel's
|
||||||
|
//! `BiStream`, and runs [`crate::adapter::drive_session_pre_negotiated`]
|
||||||
|
//! on it — the same three-pump session driver as the direct-ALPN path,
|
||||||
|
//! minus the wire-frame negotiation phase (the open op's `input` is
|
||||||
|
//! the negotiation, ADR-009). Backend allocation stays in the pump
|
||||||
|
//! handler (`allocate_failed` still arrives in-band as a negotiation
|
||||||
|
//! error frame — the establisher cannot carry the allocated handle
|
||||||
|
//! across to the handler; ADR-010).
|
||||||
//!
|
//!
|
||||||
//! The access control (scope-gate + ownership) is wired into the
|
//! The access control (scope-gate + ownership) is wired into the
|
||||||
//! `OperationSpec`'s `AccessControl` and enforced by the registry's
|
//! `OperationSpec`'s `AccessControl` and enforced by the registry's
|
||||||
//! `invoke`/`invoke_streaming` before the `TtyOpenHandler` runs — the
|
//! `invoke`/`invoke_streaming` before the establisher runs — the
|
||||||
//! handler itself only validates that the negotiated backend exists
|
//! establisher itself only runs the ADR-050 ownership check (the
|
||||||
//! and spawns the session driver. This is the difference from the
|
//! scope gate is the registry's); this is the difference from the
|
||||||
//! direct-ALPN `TtyAdapter`, which does its own ad-hoc scope check;
|
//! direct-ALPN `TtyAdapter`, which does its own ad-hoc scope check;
|
||||||
//! the channels path gets ACL for free from alkcall's registry.
|
//! the channels path gets ACL for free from alkcall's registry.
|
||||||
//!
|
//!
|
||||||
@@ -31,12 +39,14 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use alkcall::channels::operations::{ChannelCore, OpenHandler};
|
use alkcall::channels::operations::{
|
||||||
|
ChannelCore, Establishment, EstablishmentError, OpenEstablisher, OpenHandler,
|
||||||
|
};
|
||||||
use alkcall::core::auth::AuthContext;
|
use alkcall::core::auth::AuthContext;
|
||||||
use alkcall::core::ownership::OwnershipProvider;
|
use alkcall::core::ownership::OwnershipProvider;
|
||||||
use alkcall::core::Connection;
|
use alkcall::core::Connection;
|
||||||
use alkcall::registry::spec::{
|
use alkcall::registry::spec::{
|
||||||
AccessControl, ChannelOpenSpec, OperationSpec, OperationType, Visibility,
|
AccessControl, ChannelOpenSpec, ErrorDefinition, OperationSpec, OperationType, Visibility,
|
||||||
};
|
};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
@@ -107,8 +117,16 @@ pub fn register_openable(
|
|||||||
auth: AuthContext,
|
auth: AuthContext,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let spec = tty_open_spec();
|
let spec = tty_open_spec();
|
||||||
let open_handler = make_tty_open_handler(backends, ownership, auth.identity.clone());
|
let establisher = make_tty_establisher(Arc::clone(&backends), ownership, auth.identity.clone());
|
||||||
core.register_openable(spec, open_handler, registry, auth)
|
let open_handler = make_tty_open_handler(backends, auth.identity.clone());
|
||||||
|
core.register_openable_with_establisher(
|
||||||
|
spec,
|
||||||
|
Some(establisher),
|
||||||
|
open_handler,
|
||||||
|
registry,
|
||||||
|
auth,
|
||||||
|
None,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the [`OperationSpec`] for `channels/tty/sub`.
|
/// Build the [`OperationSpec`] for `channels/tty/sub`.
|
||||||
@@ -130,9 +148,28 @@ pub fn register_openable(
|
|||||||
/// here would create a second definition to drift. The registry check
|
/// here would create a second definition to drift. The registry check
|
||||||
/// catches structurally-broken opens (missing command, wrong types);
|
/// catches structurally-broken opens (missing command, wrong types);
|
||||||
/// the full parse and semantic validation (`carriage == "raw"`,
|
/// the full parse and semantic validation (`carriage == "raw"`,
|
||||||
/// backend lookup) runs in the handler via
|
/// backend lookup, ownership) runs in the establisher (ADR-010 —
|
||||||
/// [`crate::adapter::drive_session_pre_negotiated`].
|
/// failures are `channel:open_failed`); `backend.allocate` runs in the
|
||||||
|
/// pump handler.
|
||||||
|
///
|
||||||
|
/// The op declares an [`ErrorDefinition`] for `channel:open_failed`
|
||||||
|
/// (ADR-016 — establishment failures are disclosed so `services/schema`
|
||||||
|
/// shows the failure contract; reason ∈ `unknown_resource` /
|
||||||
|
/// `handler_error` / `timeout`, per the ADR-049 vocabulary — see
|
||||||
|
/// `make_tty_establisher` for the mapping) and carries a
|
||||||
|
/// `description` (review 006 E-02 — discovery enrichment).
|
||||||
pub fn tty_open_spec() -> OperationSpec {
|
pub fn tty_open_spec() -> OperationSpec {
|
||||||
|
let open_failed_schema = json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"reason": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["unknown_resource", "handler_error", "timeout"]
|
||||||
|
},
|
||||||
|
"message": { "type": "string" }
|
||||||
|
},
|
||||||
|
"required": ["reason", "message"]
|
||||||
|
});
|
||||||
OperationSpec::new(
|
OperationSpec::new(
|
||||||
OP_TTY_OPEN,
|
OP_TTY_OPEN,
|
||||||
OperationType::Sub,
|
OperationType::Sub,
|
||||||
@@ -155,7 +192,16 @@ pub fn tty_open_spec() -> OperationSpec {
|
|||||||
"channel_id": { "type": "integer", "minimum": 0 }
|
"channel_id": { "type": "integer", "minimum": 0 }
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
vec![],
|
vec![ErrorDefinition {
|
||||||
|
code: "channel:open_failed".to_string(),
|
||||||
|
description: "Establishment failed after channel allocation: the establisher \
|
||||||
|
rejected the negotiation (malformed negotiation, unknown backend, \
|
||||||
|
ownership denial) or the establishment deadline expired. Details: \
|
||||||
|
{ reason, message }. No channel_id is returned."
|
||||||
|
.to_string(),
|
||||||
|
schema: open_failed_schema,
|
||||||
|
http_status: None,
|
||||||
|
}],
|
||||||
AccessControl {
|
AccessControl {
|
||||||
required_scopes: vec![TTY_OPEN_SCOPE.to_string()],
|
required_scopes: vec![TTY_OPEN_SCOPE.to_string()],
|
||||||
required_scopes_any: None,
|
required_scopes_any: None,
|
||||||
@@ -164,45 +210,129 @@ pub fn tty_open_spec() -> OperationSpec {
|
|||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
.with_description(
|
||||||
|
"Open a terminal session (alk/tty). The open op's input is the \
|
||||||
|
NegotiateRequest (ADR-009); the data plane is TTY's 5-byte chunk \
|
||||||
|
format on the allocated channel.",
|
||||||
|
)
|
||||||
.with_channel_open(ChannelOpenSpec::new(TTY_ALPN))
|
.with_channel_open(ChannelOpenSpec::new(TTY_ALPN))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the [`OpenEstablisher`] for `channels/tty/sub` — the awaited
|
||||||
|
/// establishment phase (alkcall ADR-049 §1, alktty ADR-010).
|
||||||
|
///
|
||||||
|
/// The establisher runs the semantic validation that used to be
|
||||||
|
/// post-open error frames inside the pump handler (ADR-009 R4): the
|
||||||
|
/// full `NegotiateRequest` parse of the registry-schema-validated
|
||||||
|
/// `input` (the schema is deliberately partial so the opaque ADR-053
|
||||||
|
/// backend params pass through), `carriage == "raw"`, non-empty
|
||||||
|
/// `cmd`, the backend lookup, and the ADR-050 ownership check. Any
|
||||||
|
/// rejection resolves `Err(EstablishmentError)` and the wrapper
|
||||||
|
/// replies `channel:open_failed` with `details.reason` — the SSH
|
||||||
|
/// contract: the channel never exists opener-side, no `channel_id` is
|
||||||
|
/// ever returned.
|
||||||
|
///
|
||||||
|
/// The `tty:open` scope gate is NOT checked here — the registry's
|
||||||
|
/// `AccessControl` enforced it before the establisher ran (the
|
||||||
|
/// identity view the registry checked, resolved from the connection,
|
||||||
|
/// is the authoritative one; the establisher-side identity is only
|
||||||
|
/// the ownership-check subject — the same split as the old
|
||||||
|
/// handler-side check).
|
||||||
|
///
|
||||||
|
/// Mapping into the ADR-049 reason vocabulary (no new codes — the
|
||||||
|
/// vocabulary is alkcall's one-way wire surface):
|
||||||
|
///
|
||||||
|
/// - `NegotiateRequest` parse failure, `carriage != "raw"`, empty
|
||||||
|
/// `cmd` → [`EstablishmentError::HandlerError`] (`malformed_
|
||||||
|
/// negotiation` semantics — the request shape is wrong).
|
||||||
|
/// - Unknown backend → [`EstablishmentError::UnknownResource`].
|
||||||
|
/// - Ownership denial → [`EstablishmentError::HandlerError`] (an ACL
|
||||||
|
/// outcome, not a dial outcome; the registry's scope gate stays the
|
||||||
|
/// pre-allocation `FORBIDDEN` path).
|
||||||
|
///
|
||||||
|
/// Backend allocation deliberately does NOT happen here: the
|
||||||
|
/// establisher has no way to hand the allocated `TtyHandle` to the
|
||||||
|
/// pump handler (`Establishment` is payloadless — reserved for a
|
||||||
|
/// channel plan), and a second `allocate` for the same open would
|
||||||
|
/// violate the kill-on-`Drop` contract (ADR-005). `allocate_failed`
|
||||||
|
/// therefore stays a post-open in-band negotiation error frame
|
||||||
|
/// (ADR-010).
|
||||||
|
fn make_tty_establisher(
|
||||||
|
backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>,
|
||||||
|
ownership: Option<Arc<dyn OwnershipProvider>>,
|
||||||
|
identity: Option<alkcall::core::auth::Identity>,
|
||||||
|
) -> OpenEstablisher {
|
||||||
|
Arc::new(move |input: Value, auth: AuthContext| {
|
||||||
|
let backends = Arc::clone(&backends);
|
||||||
|
let ownership = ownership.clone();
|
||||||
|
let identity = identity.clone().or_else(|| auth.identity.clone());
|
||||||
|
Box::pin(async move {
|
||||||
|
let req: NegotiateRequest = match serde_json::from_value(input) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
debug!("tty: channels establisher: invalid negotiate params: {e}");
|
||||||
|
return Err(EstablishmentError::HandlerError {
|
||||||
|
message: format!("malformed negotiation: {e}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if req.carriage != "raw" {
|
||||||
|
return Err(EstablishmentError::HandlerError {
|
||||||
|
message: "malformed negotiation: carriage must be 'raw'".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if req.cmd.is_empty() {
|
||||||
|
return Err(EstablishmentError::HandlerError {
|
||||||
|
message: "malformed negotiation: cmd must be non-empty".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let backend = match backends.get(&req.backend) {
|
||||||
|
Some(b) => Arc::clone(b),
|
||||||
|
None => {
|
||||||
|
return Err(EstablishmentError::UnknownResource {
|
||||||
|
message: format!("unknown backend: {}", req.backend),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let params = crate::backend::TtyParams::from(req);
|
||||||
|
if let Some(provider) = ownership {
|
||||||
|
if let Some((kind, id)) = backend.resource_id(¶ms) {
|
||||||
|
let owns = identity
|
||||||
|
.as_ref()
|
||||||
|
.map(|id_ref| provider.owns(id_ref, kind, &id, "tty"))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !owns {
|
||||||
|
debug!("tty: channels establisher: ownership denied");
|
||||||
|
return Err(EstablishmentError::HandlerError {
|
||||||
|
message: "forbidden: caller does not own the requested tty \
|
||||||
|
resource"
|
||||||
|
.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Establishment {})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the [`OpenHandler`] for `channels/tty/sub`.
|
/// Build the [`OpenHandler`] for `channels/tty/sub`.
|
||||||
///
|
///
|
||||||
/// The handler receives the open op's `input` — the `NegotiateRequest`
|
/// The handler receives the open op's `input` — the `NegotiateRequest`
|
||||||
/// params, validated against the spec's input schema by the registry
|
/// params, validated by the establisher before the handler was spawned
|
||||||
/// (alkcall 0.4: `input_schema` is enforced at dispatch) and passed
|
/// (alkcall ADR-049 §1: the establisher is awaited bounded, before the
|
||||||
/// through as the authoritative negotiation (ADR-009: the channels
|
/// reply and before the pump handler) — re-parses it, calls
|
||||||
/// path does not carry a second negotiation frame on the channel; the
|
/// `accept_bi()` to get the channel's [`BiStream`], splits it into
|
||||||
/// open op's `input` *is* the negotiation). The channel's
|
|
||||||
/// [`Connection`] (data-plane ALPN `alk/tty`) and the peer's
|
|
||||||
/// [`AuthContext`] come with it. The handler parses `input`, calls
|
|
||||||
/// `accept_bi()` to get the channel's `BiStream`, splits it into
|
|
||||||
/// read/write halves (the stdlib `tokio::io::split` idiom — the same
|
/// read/write halves (the stdlib `tokio::io::split` idiom — the same
|
||||||
/// split the direct-ALPN `TtyAdapter::handle` does), and runs
|
/// split the direct-ALPN `TtyAdapter::handle` does), and runs
|
||||||
/// [`crate::adapter::drive_session_pre_negotiated`] on them — the same
|
/// [`crate::adapter::drive_session_pre_negotiated`] on them — the same
|
||||||
/// three-pump session driver as the direct-ALPN path, minus the
|
/// three-pump session driver as the direct-ALPN path. The establisher
|
||||||
/// wire-frame negotiation phase.
|
/// already rejected the semantic-failure classes (malformed params,
|
||||||
///
|
/// `carriage != "raw"`, unknown backend, ownership denial) as
|
||||||
/// All failure classes write a negotiation error frame on the channel
|
/// `channel:open_failed`, so the driver's `validate_and_allocate` pass
|
||||||
/// stream (the same `0x00`-prefixed framing the direct path uses), so a
|
/// on this path only re-runs the allocation — its negotiation error
|
||||||
/// consumer's negotiation-error disambiguation read applies unchanged:
|
/// frames remain reachable only for `allocate_failed` (the establisher
|
||||||
///
|
/// cannot carry the handle across — ADR-010).
|
||||||
/// - The registry rejects schema-invalid `input` at dispatch (alkcall
|
|
||||||
/// 0.4) — the open op fails, no channel is ever allocated, and the
|
|
||||||
/// failure is a `CallError` on the open op, not a channel-stream
|
|
||||||
/// error frame. But the schema is deliberately partial (the opaque
|
|
||||||
/// ADR-053 backend params pass through), so a schema-valid value can
|
|
||||||
/// still fail the full `NegotiateRequest` parse — e.g. `cwd` typed
|
|
||||||
/// as a number — and that failure is post-open: the handler writes a
|
|
||||||
/// `malformed_negotiation` error frame (the same
|
|
||||||
/// [`crate::adapter::send_negotiation_error`] the other failure
|
|
||||||
/// classes use) and the consumer's peek surfaces
|
|
||||||
/// `NegotiationRejected`.
|
|
||||||
/// - Semantic validation failures (unknown backend, `carriage != "raw"`,
|
|
||||||
/// empty `cmd`, `allocate_failed`, ADR-050 ownership denial) are
|
|
||||||
/// post-open error frames written by `validate_and_allocate` inside
|
|
||||||
/// [`crate::adapter::drive_session_pre_negotiated`].
|
|
||||||
///
|
///
|
||||||
/// The `tty:open` scope gate is enforced by the registry's `AccessControl`
|
/// The `tty:open` scope gate is enforced by the registry's `AccessControl`
|
||||||
/// before this handler runs — the handler does not re-check it.
|
/// before this handler runs — the handler does not re-check it.
|
||||||
@@ -215,13 +345,11 @@ pub fn tty_open_spec() -> OperationSpec {
|
|||||||
/// `manager.teardown_channel` and `policy.on_close` (ADR-047 §7).
|
/// `manager.teardown_channel` and `policy.on_close` (ADR-047 §7).
|
||||||
fn make_tty_open_handler(
|
fn make_tty_open_handler(
|
||||||
backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>,
|
backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>,
|
||||||
ownership: Option<Arc<dyn OwnershipProvider>>,
|
|
||||||
identity: Option<alkcall::core::auth::Identity>,
|
identity: Option<alkcall::core::auth::Identity>,
|
||||||
) -> OpenHandler {
|
) -> OpenHandler {
|
||||||
Arc::new(
|
Arc::new(
|
||||||
move |input: Value, channel_conn: Connection, auth: AuthContext| {
|
move |input: Value, channel_conn: Connection, auth: AuthContext| {
|
||||||
let backends = Arc::clone(&backends);
|
let backends = Arc::clone(&backends);
|
||||||
let ownership = ownership.clone();
|
|
||||||
let identity = identity.clone().or_else(|| auth.identity.clone());
|
let identity = identity.clone().or_else(|| auth.identity.clone());
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -259,7 +387,7 @@ fn make_tty_open_handler(
|
|||||||
client_read,
|
client_read,
|
||||||
req,
|
req,
|
||||||
backends,
|
backends,
|
||||||
ownership,
|
None,
|
||||||
identity,
|
identity,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -279,15 +407,17 @@ mod tests {
|
|||||||
use std::collections::HashMap as StdHashMap;
|
use std::collections::HashMap as StdHashMap;
|
||||||
use tokio::io::duplex;
|
use tokio::io::duplex;
|
||||||
|
|
||||||
/// The R4 unit-testable seam: feed `make_tty_open_handler` a
|
/// The R4 unit-testable seam, retained as a defense-in-depth gate:
|
||||||
/// hand-built `input` the (partial) schema would accept but the full
|
/// feed `make_tty_open_handler` a hand-built `input` the (partial)
|
||||||
/// `NegotiateRequest` parse rejects (`cwd` typed as a number). The
|
/// schema would accept but the full `NegotiateRequest` parse rejects
|
||||||
/// handler must write a `0x00`-prefixed `malformed_negotiation` error
|
/// (`cwd` typed as a number). Post-ADR-010 the establisher rejects
|
||||||
/// frame on the channel stream — the same failure class as the
|
/// this class before the handler ever spawns — this test pins the
|
||||||
/// post-open semantic failures, not a silent teardown. The read side
|
/// handler's own parse arm, which must still write a
|
||||||
/// is the consumer's exact post-open sequence (`from_halves_raw`):
|
/// `0x00`-prefixed `malformed_negotiation` error frame rather than
|
||||||
/// peek the first byte, see `0x00`, read the length-prefixed error
|
/// silently EOFing if that input ever reaches it (e.g. a
|
||||||
/// frame.
|
/// no-establisher registration). The read side is the consumer's
|
||||||
|
/// exact post-open sequence (`from_halves_raw`): peek the first
|
||||||
|
/// byte, see `0x00`, read the length-prefixed error frame.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn open_handler_writes_error_frame_on_schema_bypassing_input() {
|
async fn open_handler_writes_error_frame_on_schema_bypassing_input() {
|
||||||
use alkcall::core::types::{BiStream, BidiStreamSource, StreamError};
|
use alkcall::core::types::{BiStream, BidiStreamSource, StreamError};
|
||||||
@@ -329,7 +459,7 @@ mod tests {
|
|||||||
|
|
||||||
let mut backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
|
let mut backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
|
||||||
backends.insert("mock".to_string(), Arc::new(MockBackend::with_exit_code(0)));
|
backends.insert("mock".to_string(), Arc::new(MockBackend::with_exit_code(0)));
|
||||||
let handler = make_tty_open_handler(Arc::new(backends), None, None);
|
let handler = make_tty_open_handler(Arc::new(backends), None);
|
||||||
|
|
||||||
let task = handler(
|
let task = handler(
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
@@ -369,6 +499,187 @@ mod tests {
|
|||||||
assert_eq!(OP_TTY_OPEN, "channels/tty/sub");
|
assert_eq!(OP_TTY_OPEN, "channels/tty/sub");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- ADR-010: the establisher (semantic validation → channel:open_failed)
|
||||||
|
|
||||||
|
fn mock_backends_one() -> Arc<HashMap<String, Arc<dyn TtyBackend>>> {
|
||||||
|
let mut backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
|
||||||
|
backends.insert("mock".to_string(), Arc::new(MockBackend::with_exit_code(0)));
|
||||||
|
Arc::new(backends)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn establisher_result(
|
||||||
|
backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>,
|
||||||
|
ownership: Option<Arc<dyn OwnershipProvider>>,
|
||||||
|
input: Value,
|
||||||
|
) -> Result<Establishment, EstablishmentError> {
|
||||||
|
let establisher = make_tty_establisher(backends, ownership, None);
|
||||||
|
establisher(input, AuthContext::anonymous(b"test")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn establisher_ok_on_valid_params() {
|
||||||
|
let result = establisher_result(
|
||||||
|
mock_backends_one(),
|
||||||
|
None,
|
||||||
|
json!({ "carriage": "raw", "backend": "mock", "cmd": ["true"] }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_ok(), "valid params establish, got {result:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn establisher_rejects_schema_bypassing_parse() {
|
||||||
|
let result = establisher_result(
|
||||||
|
mock_backends_one(),
|
||||||
|
None,
|
||||||
|
json!({ "carriage": "raw", "backend": "mock", "cmd": ["true"], "cwd": 42 }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Err(e) => {
|
||||||
|
assert_eq!(e.reason(), "handler_error");
|
||||||
|
assert!(e.message().contains("malformed negotiation"));
|
||||||
|
}
|
||||||
|
Ok(_) => panic!("schema-bypassing input must be rejected by the establisher"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn establisher_rejects_non_raw_carriage() {
|
||||||
|
let result = establisher_result(
|
||||||
|
mock_backends_one(),
|
||||||
|
None,
|
||||||
|
json!({ "carriage": "line", "backend": "mock", "cmd": ["true"] }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Err(e) => {
|
||||||
|
assert_eq!(e.reason(), "handler_error");
|
||||||
|
assert!(e.message().contains("carriage must be 'raw'"));
|
||||||
|
}
|
||||||
|
Ok(_) => panic!("non-raw carriage must be rejected"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn establisher_rejects_empty_cmd() {
|
||||||
|
let result = establisher_result(
|
||||||
|
mock_backends_one(),
|
||||||
|
None,
|
||||||
|
json!({ "carriage": "raw", "backend": "mock", "cmd": [] }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Err(e) => {
|
||||||
|
assert_eq!(e.reason(), "handler_error");
|
||||||
|
assert!(e.message().contains("cmd must be non-empty"));
|
||||||
|
}
|
||||||
|
Ok(_) => panic!("empty cmd must be rejected"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn establisher_rejects_unknown_backend_as_unknown_resource() {
|
||||||
|
let result = establisher_result(
|
||||||
|
mock_backends_one(),
|
||||||
|
None,
|
||||||
|
json!({ "carriage": "raw", "backend": "nope", "cmd": ["true"] }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Err(e) => {
|
||||||
|
assert_eq!(e.reason(), "unknown_resource");
|
||||||
|
assert_eq!(e.message(), "unknown backend: nope");
|
||||||
|
}
|
||||||
|
Ok(_) => panic!("unknown backend must be rejected"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ADR-050 ownership check runs in the establisher (moved from
|
||||||
|
/// the pump handler's `validate_and_allocate`); a denial is
|
||||||
|
/// `handler_error` (an ACL outcome, not a dial outcome).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn establisher_rejects_ownership_denial() {
|
||||||
|
use alkcall::core::OwnershipStore;
|
||||||
|
|
||||||
|
let store = alkcall::core::ownership::InMemoryOwnershipStore::new();
|
||||||
|
// The requested resource is owned by someone else (or nobody).
|
||||||
|
store
|
||||||
|
.record(
|
||||||
|
&Identity {
|
||||||
|
id: "mallory".to_string(),
|
||||||
|
scopes: vec![],
|
||||||
|
resources: HashMap::new(),
|
||||||
|
},
|
||||||
|
"session",
|
||||||
|
"s-42",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("record");
|
||||||
|
let provider: Arc<dyn OwnershipProvider> = Arc::new(store);
|
||||||
|
|
||||||
|
struct OwnedBackend;
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl TtyBackend for OwnedBackend {
|
||||||
|
async fn allocate(
|
||||||
|
&self,
|
||||||
|
_params: &crate::backend::TtyParams,
|
||||||
|
) -> Result<crate::backend::TtyHandle, crate::backend::TtyError> {
|
||||||
|
panic!("allocate must not run during establishment");
|
||||||
|
}
|
||||||
|
fn resource_id(
|
||||||
|
&self,
|
||||||
|
_params: &crate::backend::TtyParams,
|
||||||
|
) -> Option<(&'static str, String)> {
|
||||||
|
Some(("session", "s-42".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
|
||||||
|
backends.insert("mock".to_string(), Arc::new(OwnedBackend));
|
||||||
|
|
||||||
|
let result = establisher_result(
|
||||||
|
Arc::new(backends),
|
||||||
|
Some(provider),
|
||||||
|
json!({ "carriage": "raw", "backend": "mock", "cmd": ["true"] }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Err(e) => {
|
||||||
|
assert_eq!(e.reason(), "handler_error");
|
||||||
|
assert!(e.message().contains("forbidden"));
|
||||||
|
}
|
||||||
|
Ok(_) => panic!("ownership denial must be rejected by the establisher"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// E-02: the spec carries a description (disclosed by
|
||||||
|
/// `services/list`) and declares the `channel:open_failed` error
|
||||||
|
/// schema (ADR-016 — the establishment-failure contract is
|
||||||
|
/// discoverable via `services/schema`).
|
||||||
|
#[test]
|
||||||
|
fn tty_open_spec_declares_description_and_open_failed_error() {
|
||||||
|
let spec = tty_open_spec();
|
||||||
|
assert!(
|
||||||
|
spec.description.is_some(),
|
||||||
|
"description set for services/list disclosure (review 006 E-02)"
|
||||||
|
);
|
||||||
|
let def = spec
|
||||||
|
.error_schemas
|
||||||
|
.iter()
|
||||||
|
.find(|e| e.code == "channel:open_failed")
|
||||||
|
.expect("channel:open_failed ErrorDefinition declared");
|
||||||
|
let enum_values = def.schema["properties"]["reason"]["enum"]
|
||||||
|
.as_array()
|
||||||
|
.expect("reason enum");
|
||||||
|
let reasons: Vec<&str> = enum_values.iter().filter_map(|v| v.as_str()).collect();
|
||||||
|
// The establisher produces only these three (dial_failed and
|
||||||
|
// resource_shortage have no TTY producer path — ADR-010).
|
||||||
|
assert_eq!(
|
||||||
|
reasons,
|
||||||
|
vec!["unknown_resource", "handler_error", "timeout"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tty_alpn_is_alk_tty() {
|
fn tty_alpn_is_alk_tty() {
|
||||||
assert_eq!(TTY_ALPN, "alk/tty");
|
assert_eq!(TTY_ALPN, "alk/tty");
|
||||||
|
|||||||
+56
-21
@@ -20,7 +20,11 @@
|
|||||||
//! half, and runs the typed-methods flow directly in raw chunk mode
|
//! half, and runs the typed-methods flow directly in raw chunk mode
|
||||||
//! (ADR-009: the open op's `params` — validated by the registry's
|
//! (ADR-009: the open op's `params` — validated by the registry's
|
||||||
//! input schema — *are* the negotiation; no second negotiation
|
//! input schema — *are* the negotiation; no second negotiation
|
||||||
//! frame is written on the channel's data stream).
|
//! frame is written on the channel's data stream). Semantic
|
||||||
|
//! establishment failures are rejected by the producer's establisher
|
||||||
|
//! and surface as `channel:open_failed` call errors (alkcall 0.5.0
|
||||||
|
//! ADR-049 / alktty ADR-010); allocation failure stays in-band
|
||||||
|
//! (see `open_via_channels`' docs).
|
||||||
//!
|
//!
|
||||||
//! The session handle exposes:
|
//! The session handle exposes:
|
||||||
//! - [`TtySession::send_stdin`] / [`TtySession::close_stdin`] — write
|
//! - [`TtySession::send_stdin`] / [`TtySession::close_stdin`] — write
|
||||||
@@ -91,10 +95,19 @@ pub enum TtySessionError {
|
|||||||
/// The negotiation frame failed to write (framing I/O error).
|
/// The negotiation frame failed to write (framing I/O error).
|
||||||
#[error("negotiation write: {0}")]
|
#[error("negotiation write: {0}")]
|
||||||
NegotiationWrite(#[from] NegotiationError),
|
NegotiationWrite(#[from] NegotiationError),
|
||||||
/// The channels open op failed (returned a `CallError` or
|
/// The channels open op failed. Carries alkcall's typed
|
||||||
/// `channel_id` missing from the response).
|
/// `ChannelOpenError` verbatim (alkcall 0.5.0, ADR-049 §4): a
|
||||||
|
/// `CallFailed` variant wraps the wire `CallError`, so a
|
||||||
|
/// semantic-establishment failure (unknown backend, malformed
|
||||||
|
/// negotiation, ownership denial — rejected by the producer's
|
||||||
|
/// establisher) surfaces as `channel:open_failed` with
|
||||||
|
/// `details.reason` branchable via
|
||||||
|
/// `ChannelOpenError::establishment_reason()`; ACL denial and
|
||||||
|
/// unknown-op failures carry their own codes (`FORBIDDEN`,
|
||||||
|
/// `UNKNOWN_OPERATION`). `MissingChannelId` / `AdoptFailed` are
|
||||||
|
/// the local-only failure shapes.
|
||||||
#[error("channels open: {0}")]
|
#[error("channels open: {0}")]
|
||||||
ChannelsOpen(String),
|
ChannelsOpen(#[from] alkcall::channels::client::ChannelOpenError),
|
||||||
/// The channels open op's `params` failed the consumer's local
|
/// The channels open op's `params` failed the consumer's local
|
||||||
/// `NegotiateRequest` parse — the fail-fast check in
|
/// `NegotiateRequest` parse — the fail-fast check in
|
||||||
/// [`TtySession::open_via_channels`] before a channel is allocated.
|
/// [`TtySession::open_via_channels`] before a channel is allocated.
|
||||||
@@ -218,13 +231,20 @@ impl TtySession {
|
|||||||
/// Failures before the channel opens (ACL denial, unknown op,
|
/// Failures before the channel opens (ACL denial, unknown op,
|
||||||
/// channel cap) surface as [`TtySessionError::ChannelsOpen`]; a
|
/// channel cap) surface as [`TtySessionError::ChannelsOpen`]; a
|
||||||
/// params value that fails the local `NegotiateRequest` parse
|
/// params value that fails the local `NegotiateRequest` parse
|
||||||
/// surfaces as [`TtySessionError::InvalidParams`]. Post-open
|
/// surfaces as [`TtySessionError::InvalidParams`]. Semantic
|
||||||
/// failures (a `NegotiateRequest` parse failure of a
|
/// establishment failures (a `NegotiateRequest` parse failure of a
|
||||||
/// schema-valid-but-unparseable params value, unknown backend,
|
/// schema-valid-but-unparseable params value, `carriage != "raw"`,
|
||||||
/// allocate failure, ownership denial) arrive as a negotiation
|
/// empty `cmd`, unknown backend, ownership denial) are rejected by
|
||||||
/// error frame on the channel stream — the session surfaces those
|
/// the producer's establisher before the reply — they surface as
|
||||||
/// as [`TtySessionError::NegotiationRejected`] via the same `0x00`
|
/// [`TtySessionError::ChannelsOpen`] wrapping a `CallFailed` whose
|
||||||
/// disambiguation read the direct path uses.
|
/// `CallError` is `channel:open_failed` with
|
||||||
|
/// `details.reason` (alkcall 0.5.0 ADR-049; the SSH contract: no
|
||||||
|
/// `channel_id` is ever returned). The one post-open failure class
|
||||||
|
/// that still arrives in-band is allocation failure
|
||||||
|
/// (`allocate_failed`): it surfaces as
|
||||||
|
/// [`TtySessionError::NegotiationRejected`] via the `0x00`
|
||||||
|
/// disambiguation read (the establisher cannot carry the allocated
|
||||||
|
/// backend handle across to the pump handler — ADR-010).
|
||||||
pub async fn open_via_channels(
|
pub async fn open_via_channels(
|
||||||
client: &ChannelClient,
|
client: &ChannelClient,
|
||||||
params: serde_json::Value,
|
params: serde_json::Value,
|
||||||
@@ -243,7 +263,7 @@ impl TtySession {
|
|||||||
crate::channels::TTY_ALPN,
|
crate::channels::TTY_ALPN,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(TtySessionError::ChannelsOpen)?;
|
.map_err(TtySessionError::from)?;
|
||||||
debug!("tty: opened channel {channel_id} via channels");
|
debug!("tty: opened channel {channel_id} via channels");
|
||||||
|
|
||||||
let remote_addr = client.manager().remote_addr();
|
let remote_addr = client.manager().remote_addr();
|
||||||
@@ -1203,10 +1223,15 @@ mod tests {
|
|||||||
assert_eq!(code, 0);
|
assert_eq!(code, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The same end-to-end path with an unknown backend: the producer
|
/// The ADR-049 §5 migration gate: an unknown backend is rejected by
|
||||||
/// sends a `0x00`-prefixed error frame on the channel stream, the
|
/// the producer's establisher before the reply — the open op fails
|
||||||
/// consumer's post-open peek disambiguates it, and the session
|
/// with `channel:open_failed` + `details.reason == "unknown_resource"`
|
||||||
/// surfaces `NegotiationRejected` (M1 on the channels path).
|
/// (the SSH "channel never exists opener-side" contract: no
|
||||||
|
/// `channel_id`, no session), instead of the pre-0.2.0 post-open
|
||||||
|
/// in-band `unknown_backend` negotiation error frame
|
||||||
|
/// (`NegotiationRejected`). The direct-ALPN path keeps the in-band
|
||||||
|
/// frame (two transports, two contracts — see
|
||||||
|
/// `connect_direct_returns_negotiation_rejected`).
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn open_via_channels_surfaces_negotiation_rejected() {
|
async fn open_via_channels_surfaces_negotiation_rejected() {
|
||||||
let client =
|
let client =
|
||||||
@@ -1219,13 +1244,23 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.expect("open_via_channels timed out");
|
.expect("open_via_channels timed out");
|
||||||
match result {
|
match result {
|
||||||
Err(TtySessionError::NegotiationRejected { error, fields }) => {
|
Err(TtySessionError::ChannelsOpen(
|
||||||
assert_eq!(error, "unknown_backend");
|
alkcall::channels::client::ChannelOpenError::CallFailed { error },
|
||||||
assert_eq!(fields.get("backend").map(String::as_str), Some("nope"));
|
)) => {
|
||||||
|
assert_eq!(error.code, "channel:open_failed");
|
||||||
|
let details = error.details.expect("details carry the reason");
|
||||||
|
assert_eq!(details["reason"], "unknown_resource");
|
||||||
|
assert_eq!(details["message"], "unknown backend: nope");
|
||||||
}
|
}
|
||||||
Ok(_) => panic!("expected NegotiationRejected, got Ok(session)"),
|
Ok(_) => panic!("expected channel:open_failed, got Ok(session)"),
|
||||||
Err(other) => panic!("expected NegotiationRejected, got {other:?}"),
|
Err(other) => panic!("expected ChannelsOpen(channel:open_failed), got {other:?}"),
|
||||||
}
|
}
|
||||||
|
// The SSH contract, consumer-visible: no channel was adopted
|
||||||
|
// locally (a failed open never returns a channel_id).
|
||||||
|
assert!(
|
||||||
|
client.manager().channel_ids().iter().all(|&id| id == 0),
|
||||||
|
"no data channel survives a failed establishment (channel 0 is the call channel)"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The open op's `input` is schema-validated by the registry
|
/// The open op's `input` is schema-validated by the registry
|
||||||
|
|||||||
+79
-17
@@ -207,28 +207,26 @@ mod tests {
|
|||||||
drop(send);
|
drop(send);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// End-to-end (R4): `input` that passes the registry's (partial)
|
/// End-to-end (R4, post-ADR-010): `input` that passes the registry's
|
||||||
/// schema but fails the handler's full `NegotiateRequest` parse —
|
/// (partial) schema but fails the establisher's full
|
||||||
/// `cwd` typed as a number. The open op succeeds (the schema is
|
/// `NegotiateRequest` parse — `cwd` typed as a number. The
|
||||||
/// deliberately partial), the producer-side handler writes a
|
/// establisher rejects it before the reply: the open op fails with
|
||||||
/// `0x00`-prefixed `malformed_negotiation` error frame on the
|
/// `channel:open_failed` + `details.reason == "handler_error"` (the
|
||||||
/// channel stream, and the consumer-side post-open read sequence
|
/// malformed-negotiation mapping), the channel is torn down, and no
|
||||||
/// (`from_halves_raw`'s peek + error-frame parse) observes it — the
|
/// `channel_id` is ever returned. Uses `ChannelClient::open_channel`
|
||||||
/// same failure class as the post-open semantic failures, not a
|
/// directly (the consumer's local fail-fast parse in
|
||||||
/// silent teardown. Uses `ChannelClient::open_channel` directly
|
/// `open_via_channels` would reject these params before the open op
|
||||||
/// (the consumer's local fail-fast parse in `open_via_channels`
|
/// runs — R5's fail-fast path).
|
||||||
/// would reject these params before the open op runs — R5's
|
|
||||||
/// fail-fast path).
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn schema_valid_but_unparseable_input_gets_malformed_negotiation_frame() {
|
async fn schema_valid_but_unparseable_input_fails_open_with_open_failed() {
|
||||||
use tokio::io::AsyncReadExt;
|
use alkcall::channels::client::ChannelOpenError;
|
||||||
|
|
||||||
let mut backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
|
let mut backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
|
||||||
backends.insert("mock".to_string(), Arc::new(MockBackend::with_exit_code(0)));
|
backends.insert("mock".to_string(), Arc::new(MockBackend::with_exit_code(0)));
|
||||||
let client =
|
let client =
|
||||||
wire_client_and_server(Arc::new(backends), None, Some(tty_identity("alice"))).await;
|
wire_client_and_server(Arc::new(backends), None, Some(tty_identity("alice"))).await;
|
||||||
|
|
||||||
let (_channel_id, _send, mut recv) = tokio::time::timeout(
|
let result = tokio::time::timeout(
|
||||||
std::time::Duration::from_secs(5),
|
std::time::Duration::from_secs(5),
|
||||||
client.open_channel(
|
client.open_channel(
|
||||||
OP_TTY_OPEN,
|
OP_TTY_OPEN,
|
||||||
@@ -242,8 +240,72 @@ mod tests {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
.expect("open_channel timed out");
|
||||||
|
match result {
|
||||||
|
Err(ChannelOpenError::CallFailed { error }) => {
|
||||||
|
assert_eq!(error.code, "channel:open_failed");
|
||||||
|
let details = error.details.expect("details carry the reason");
|
||||||
|
assert_eq!(details["reason"], "handler_error");
|
||||||
|
assert!(
|
||||||
|
details["message"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|m| m.contains("malformed negotiation")),
|
||||||
|
"message carries the malformed-negotiation detail, got {details}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => panic!("expected channel:open_failed, got Ok(channel)"),
|
||||||
|
Err(other) => panic!("expected CallFailed(channel:open_failed), got {other:?}"),
|
||||||
|
}
|
||||||
|
// The SSH contract: no data channel survives a failed
|
||||||
|
// establishment (channel 0 is the call channel).
|
||||||
|
assert!(
|
||||||
|
client.manager().channel_ids().iter().all(|&id| id == 0),
|
||||||
|
"no channel survives a failed establishment"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Post-ADR-010, the one failure class that still arrives in-band on
|
||||||
|
/// the channels path is allocation failure (`allocate_failed`): the
|
||||||
|
/// establisher cannot carry the allocated `TtyHandle` across to the
|
||||||
|
/// pump handler (ADR-010), so `backend.allocate` runs in the handler
|
||||||
|
/// and its failure surfaces as a `0x00`-prefixed
|
||||||
|
/// `allocate_failed` negotiation error frame via the consumer's
|
||||||
|
/// `from_halves_raw` peek. Pins the boundary of the establisher
|
||||||
|
/// migration.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn allocate_failure_still_arrives_in_band_on_channels_path() {
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
|
struct AllocFailBackend;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl TtyBackend for AllocFailBackend {
|
||||||
|
async fn allocate(
|
||||||
|
&self,
|
||||||
|
_params: &crate::backend::TtyParams,
|
||||||
|
) -> Result<crate::backend::TtyHandle, crate::backend::TtyError> {
|
||||||
|
Err(crate::backend::TtyError::AllocFailed {
|
||||||
|
message: "out of ptys".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
|
||||||
|
backends.insert("mock".to_string(), Arc::new(AllocFailBackend));
|
||||||
|
let client =
|
||||||
|
wire_client_and_server(Arc::new(backends), None, Some(tty_identity("alice"))).await;
|
||||||
|
|
||||||
|
let (_channel_id, _send, mut recv) = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(5),
|
||||||
|
client.open_channel(
|
||||||
|
OP_TTY_OPEN,
|
||||||
|
serde_json::json!({ "carriage": "raw", "backend": "mock", "cmd": ["true"] }),
|
||||||
|
crate::channels::TTY_ALPN,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
.expect("open_channel timed out")
|
.expect("open_channel timed out")
|
||||||
.expect("open_channel — the partial schema accepts cwd: 42");
|
.expect("open_channel — establishment succeeds, allocation is post-open");
|
||||||
|
|
||||||
// The consumer-side post-open sequence (`from_halves_raw`):
|
// The consumer-side post-open sequence (`from_halves_raw`):
|
||||||
// peek the first byte; `0x00` = negotiation error frame.
|
// peek the first byte; `0x00` = negotiation error frame.
|
||||||
@@ -263,7 +325,7 @@ mod tests {
|
|||||||
let mut body = vec![0u8; len];
|
let mut body = vec![0u8; len];
|
||||||
recv.read_exact(&mut body).await.expect("read error body");
|
recv.read_exact(&mut body).await.expect("read error body");
|
||||||
let v: serde_json::Value = serde_json::from_slice(&body).expect("parse error frame");
|
let v: serde_json::Value = serde_json::from_slice(&body).expect("parse error frame");
|
||||||
assert_eq!(v["error"], "malformed_negotiation");
|
assert_eq!(v["error"], "allocate_failed");
|
||||||
assert!(v["message"].as_str().is_some_and(|m| !m.is_empty()));
|
assert!(v["message"].as_str().is_some_and(|m| !m.is_empty()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user