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:
1 parent
66c6e693bb
commit
e2fa32b3c7
11 files changed
+842
-121
No files matched your search
+373
-62
@@ -1,5 +1,6 @@
|
||||
//! 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
|
||||
//! 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
|
||||
//! [`crate::session::TtySession`] or any `ChannelClient` caller)
|
||||
//! invokes `channels/tty/sub`, the alkcall channels wrapper does
|
||||
//! `check_open` → `open_channel` → spawn the `TtyOpenHandler` →
|
||||
//! respond with `{ channel_id }`. The `TtyOpenHandler` receives the
|
||||
//! channel's `Connection` (data-plane 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).
|
||||
//! `check_open` → `open_channel` → **await the establisher bounded**
|
||||
//! (alkcall ADR-049 §1) → spawn the `TtyOpenHandler` → respond with
|
||||
//! `{ channel_id }`. The establisher performs the semantic validation
|
||||
//! (full `NegotiateRequest` parse, `carriage == "raw"`, backend
|
||||
//! lookup, ADR-050 ownership check) and fails the open op with
|
||||
//! `channel:open_failed` on rejection — no phantom channel. The
|
||||
//! `TtyOpenHandler` receives the channel's `Connection` (data-plane
|
||||
//! 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
|
||||
//! `OperationSpec`'s `AccessControl` and enforced by the registry's
|
||||
//! `invoke`/`invoke_streaming` before the `TtyOpenHandler` runs — the
|
||||
//! handler itself only validates that the negotiated backend exists
|
||||
//! and spawns the session driver. This is the difference from the
|
||||
//! `invoke`/`invoke_streaming` before the establisher runs — the
|
||||
//! establisher itself only runs the ADR-050 ownership check (the
|
||||
//! scope gate is the registry's); this is the difference from the
|
||||
//! direct-ALPN `TtyAdapter`, which does its own ad-hoc scope check;
|
||||
//! the channels path gets ACL for free from alkcall's registry.
|
||||
//!
|
||||
@@ -31,12 +39,14 @@
|
||||
use std::collections::HashMap;
|
||||
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::ownership::OwnershipProvider;
|
||||
use alkcall::core::Connection;
|
||||
use alkcall::registry::spec::{
|
||||
AccessControl, ChannelOpenSpec, OperationSpec, OperationType, Visibility,
|
||||
AccessControl, ChannelOpenSpec, ErrorDefinition, OperationSpec, OperationType, Visibility,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use tracing::debug;
|
||||
@@ -107,8 +117,16 @@ pub fn register_openable(
|
||||
auth: AuthContext,
|
||||
) -> Result<(), String> {
|
||||
let spec = tty_open_spec();
|
||||
let open_handler = make_tty_open_handler(backends, ownership, auth.identity.clone());
|
||||
core.register_openable(spec, open_handler, registry, auth)
|
||||
let establisher = make_tty_establisher(Arc::clone(&backends), ownership, auth.identity.clone());
|
||||
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`.
|
||||
@@ -130,9 +148,28 @@ pub fn register_openable(
|
||||
/// here would create a second definition to drift. The registry check
|
||||
/// catches structurally-broken opens (missing command, wrong types);
|
||||
/// the full parse and semantic validation (`carriage == "raw"`,
|
||||
/// backend lookup) runs in the handler via
|
||||
/// [`crate::adapter::drive_session_pre_negotiated`].
|
||||
/// backend lookup, ownership) runs in the establisher (ADR-010 —
|
||||
/// 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 {
|
||||
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(
|
||||
OP_TTY_OPEN,
|
||||
OperationType::Sub,
|
||||
@@ -155,7 +192,16 @@ pub fn tty_open_spec() -> OperationSpec {
|
||||
"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 {
|
||||
required_scopes: vec![TTY_OPEN_SCOPE.to_string()],
|
||||
required_scopes_any: None,
|
||||
@@ -164,45 +210,129 @@ pub fn tty_open_spec() -> OperationSpec {
|
||||
},
|
||||
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))
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
///
|
||||
/// The handler receives the open op's `input` — the `NegotiateRequest`
|
||||
/// params, validated against the spec's input schema by the registry
|
||||
/// (alkcall 0.4: `input_schema` is enforced at dispatch) and passed
|
||||
/// through as the authoritative negotiation (ADR-009: the channels
|
||||
/// path does not carry a second negotiation frame on the channel; the
|
||||
/// 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
|
||||
/// params, validated by the establisher before the handler was spawned
|
||||
/// (alkcall ADR-049 §1: the establisher is awaited bounded, before the
|
||||
/// reply and before the pump handler) — re-parses it, calls
|
||||
/// `accept_bi()` to get the channel's [`BiStream`], splits it into
|
||||
/// read/write halves (the stdlib `tokio::io::split` idiom — the same
|
||||
/// split the direct-ALPN `TtyAdapter::handle` does), and runs
|
||||
/// [`crate::adapter::drive_session_pre_negotiated`] on them — the same
|
||||
/// three-pump session driver as the direct-ALPN path, minus the
|
||||
/// wire-frame negotiation phase.
|
||||
///
|
||||
/// All failure classes write a negotiation error frame on the channel
|
||||
/// stream (the same `0x00`-prefixed framing the direct path uses), so a
|
||||
/// consumer's negotiation-error disambiguation read applies unchanged:
|
||||
///
|
||||
/// - 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`].
|
||||
/// three-pump session driver as the direct-ALPN path. The establisher
|
||||
/// already rejected the semantic-failure classes (malformed params,
|
||||
/// `carriage != "raw"`, unknown backend, ownership denial) as
|
||||
/// `channel:open_failed`, so the driver's `validate_and_allocate` pass
|
||||
/// on this path only re-runs the allocation — its negotiation error
|
||||
/// frames remain reachable only for `allocate_failed` (the establisher
|
||||
/// cannot carry the handle across — ADR-010).
|
||||
///
|
||||
/// The `tty:open` scope gate is enforced by the registry's `AccessControl`
|
||||
/// 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).
|
||||
fn make_tty_open_handler(
|
||||
backends: Arc<HashMap<String, Arc<dyn TtyBackend>>>,
|
||||
ownership: Option<Arc<dyn OwnershipProvider>>,
|
||||
identity: Option<alkcall::core::auth::Identity>,
|
||||
) -> OpenHandler {
|
||||
Arc::new(
|
||||
move |input: Value, channel_conn: Connection, auth: AuthContext| {
|
||||
let backends = Arc::clone(&backends);
|
||||
let ownership = ownership.clone();
|
||||
let identity = identity.clone().or_else(|| auth.identity.clone());
|
||||
|
||||
tokio::spawn(async move {
|
||||
@@ -259,7 +387,7 @@ fn make_tty_open_handler(
|
||||
client_read,
|
||||
req,
|
||||
backends,
|
||||
ownership,
|
||||
None,
|
||||
identity,
|
||||
)
|
||||
.await;
|
||||
@@ -279,15 +407,17 @@ mod tests {
|
||||
use std::collections::HashMap as StdHashMap;
|
||||
use tokio::io::duplex;
|
||||
|
||||
/// The R4 unit-testable seam: feed `make_tty_open_handler` a
|
||||
/// hand-built `input` the (partial) schema would accept but the full
|
||||
/// `NegotiateRequest` parse rejects (`cwd` typed as a number). The
|
||||
/// handler must write a `0x00`-prefixed `malformed_negotiation` error
|
||||
/// frame on the channel stream — the same failure class as the
|
||||
/// post-open semantic failures, not a silent teardown. 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.
|
||||
/// The R4 unit-testable seam, retained as a defense-in-depth gate:
|
||||
/// feed `make_tty_open_handler` a hand-built `input` the (partial)
|
||||
/// schema would accept but the full `NegotiateRequest` parse rejects
|
||||
/// (`cwd` typed as a number). Post-ADR-010 the establisher rejects
|
||||
/// this class before the handler ever spawns — this test pins the
|
||||
/// handler's own parse arm, which must still write a
|
||||
/// `0x00`-prefixed `malformed_negotiation` error frame rather than
|
||||
/// silently EOFing if that input ever reaches it (e.g. a
|
||||
/// 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]
|
||||
async fn open_handler_writes_error_frame_on_schema_bypassing_input() {
|
||||
use alkcall::core::types::{BiStream, BidiStreamSource, StreamError};
|
||||
@@ -329,7 +459,7 @@ mod tests {
|
||||
|
||||
let mut backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
|
||||
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(
|
||||
serde_json::json!({
|
||||
@@ -369,6 +499,187 @@ mod tests {
|
||||
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]
|
||||
fn tty_alpn_is_alk_tty() {
|
||||
assert_eq!(TTY_ALPN, "alk/tty");
|
||||
|
||||
Reference in new issue
Block a user