phase 3: port alknet-tty-local behind the local feature
Folds the alknet-tty-local crate into alktty as a feature-gated local
submodule (the single-crate + local-feature design from ADR-054; the
cyclic-dep workaround the mono-repo needed doesn't apply to one crate).
src/local/mod.rs:
- Module root, re-exports LocalTtyBackend, declares backend/pipe/pty
- Doc-commented as feature-gated + non-wasm by design (portable-pty +
tokio::process need a real OS; enabling local on a wasm target is a
build error by design)
src/local/backend.rs:
- LocalTtyBackend: implements crate::backend::TtyBackend
- allocate() dispatches on params.terminal: Some -> pty::allocate_pty,
None -> pipe::allocate_pipe (ADR-054)
- 7 tests: PTY/pipe dispatch, empty-cmd rejection (both modes),
resource_id returns None, new() constructs
src/local/pty.rs:
- portable_pty + 3 std threads (reader/writer/waiter) feeding tokio
mpsc/oneshot (REQ-TTY-01 blocking->async bridge)
- PtyControl: resize + REQ-TTY-02 process-group signal forwarding
(libc::kill(-pgid, sig) + kill(pid, sig) fallback + ChildKiller::kill
for unknown names)
- LocalExitFuture: ADR-056 kill-on-Drop guard (ChildKiller on cancel,
Option::take disarms on resolve)
- StdinSink: AsyncWrite over mpc::Sender<StdinCmd> with in-flight
reserve+send parking for full-channel backpressure
- 7 tests incl. process-group reach (bash -c sleep), cancel-cleanup,
unknown-signal fallback
src/local/pipe.rs:
- tokio::process::Command + tokio_util::io::ReaderStream
- PipeControl: no-op resize, libc::kill(pid, sig) with SIGKILL
fallback for unknown names (pid-only, no process group - documented
limitation of the runner case)
- PipeExitFuture: in-place Child::wait() poll (avoids self-referential
borrow) + ADR-056 kill-on-Drop guard (start_kill on cancel)
- BytesStream: wraps ReaderStream, strips io::Error to EOF
- 9 tests incl. separate stderr, SIGTERM=-15, SIGKILL=-9 fallback,
cancel-cleanup pid probe (kill(pid,0) returns ESRCH)
Import migration: alknet_tty::backend::{...} -> crate::backend::{...},
alknet_tty::control::signal_from_name -> crate::control::signal_from_name.
Cargo.toml: no changes needed (portable-pty + tokio-util optional, libc
under cfg(unix), and the local feature wiring tokio/process +
tokio/rt-multi-thread were already in the scaffold per Phase 0).
Also fixes pre-existing rustfmt drift in adapter.rs/channels.rs/
session.rs/lib.rs left by the phase 2 commit (cargo fmt --check without
--features local reported 28 diffs; cargo fmt does not accept
--features, so the earlier 'clean' check was a false negative — the
check errored on the unknown flag and grepped an empty stdout). Lesson:
run cargo fmt --check with no feature flags; cargo fmt doesn't gate on
features.
Verification:
- cargo test --features local -> 103/103 pass (was 80 at phase 2 end;
+23 new tests across the 3 local modules: 7 backend, 7 pty, 9 pipe)
- cargo test (no features) -> 80/80 pass (local module not compiled)
- cargo clippy --features local --all-targets -> clean
- cargo clippy --all-targets (no features) -> clean
- cargo check --target wasm32-unknown-unknown -> clean (default crate
stays wasm-clean; local is feature-gated and non-wasm by design)
- cargo fmt --check -> clean
This commit is contained in:
1 parent
765f40ae34
commit
e1610c2825
8 files changed
+1487
-163
No files matched your search
+78
-98
@@ -182,33 +182,35 @@ fn make_tty_open_handler(
|
||||
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());
|
||||
let _ = input;
|
||||
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());
|
||||
let _ = input;
|
||||
|
||||
tokio::spawn(async move {
|
||||
let stream = match channel_conn.accept_bi().await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
debug!("tty: channels open: accept_bi failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (client_read, client_write) = tokio::io::split(stream);
|
||||
drive_session(client_write, client_read, backends, ownership, identity).await;
|
||||
})
|
||||
})
|
||||
tokio::spawn(async move {
|
||||
let stream = match channel_conn.accept_bi().await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
debug!("tty: channels open: accept_bi failed: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (client_read, client_write) = tokio::io::split(stream);
|
||||
drive_session(client_write, client_read, backends, ownership, identity).await;
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backend::{MockBackend, TtyError};
|
||||
use alkcall::channels::client::ChannelClient;
|
||||
use alkcall::channels::operations::ChannelCore;
|
||||
use alkcall::channels::policy::default_policy;
|
||||
use alkcall::channels::client::ChannelClient;
|
||||
use alkcall::core::auth::Identity;
|
||||
use alkcall::core::types::Connection as CoreConnection;
|
||||
use alkcall::registry::registration::OperationRegistry;
|
||||
@@ -230,19 +232,14 @@ mod tests {
|
||||
ownership: Option<Arc<dyn OwnershipProvider>>,
|
||||
identity: Option<Identity>,
|
||||
) -> ChannelClient {
|
||||
use alkcall::channels::adapter::{
|
||||
ChannelsAdapter, InstallChannelZero,
|
||||
};
|
||||
use alkcall::channels::adapter::{ChannelsAdapter, InstallChannelZero};
|
||||
use alkcall::core::auth::IdentityProvider;
|
||||
use alkcall::protocol::connection::split_single_stream;
|
||||
use alkcall::protocol::dispatch::Dispatcher;
|
||||
|
||||
struct NoopIdProvider;
|
||||
impl IdentityProvider for NoopIdProvider {
|
||||
fn resolve_from_fingerprint(
|
||||
&self,
|
||||
_: &str,
|
||||
) -> Option<alkcall::core::auth::Identity> {
|
||||
fn resolve_from_fingerprint(&self, _: &str) -> Option<alkcall::core::auth::Identity> {
|
||||
None
|
||||
}
|
||||
fn resolve_from_token(
|
||||
@@ -257,65 +254,56 @@ mod tests {
|
||||
let policy_for_hook = Arc::clone(&policy);
|
||||
let identity_for_conn = identity.clone();
|
||||
|
||||
let install_hook: InstallChannelZero =
|
||||
Arc::new(move |manager, channel0_conn, auth| {
|
||||
let backends = Arc::clone(&backends);
|
||||
let ownership = ownership.clone();
|
||||
let _identity = identity.clone();
|
||||
let policy = Arc::clone(&policy_for_hook);
|
||||
tokio::spawn(async move {
|
||||
let channel0_bidi = match channel0_conn.accept_bi().await {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
let (writer, reader) = split_single_stream(channel0_bidi);
|
||||
let install_hook: InstallChannelZero = Arc::new(move |manager, channel0_conn, auth| {
|
||||
let backends = Arc::clone(&backends);
|
||||
let ownership = ownership.clone();
|
||||
let _identity = identity.clone();
|
||||
let policy = Arc::clone(&policy_for_hook);
|
||||
tokio::spawn(async move {
|
||||
let channel0_bidi = match channel0_conn.accept_bi().await {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
let (writer, reader) = split_single_stream(channel0_bidi);
|
||||
|
||||
// Propagate the identity to the channel-0
|
||||
// connection so the call dispatch's
|
||||
// `resolve_identity` sees it. The
|
||||
// `ChannelsAdapter` builds `channel0_conn` fresh
|
||||
// from `channel_source` and does NOT inherit the
|
||||
// outer connection's identity — we set it here
|
||||
// so the `AccessControl` scope-gate can check it.
|
||||
if let Some(id) = _identity {
|
||||
let _ = channel0_conn.set_identity(id);
|
||||
}
|
||||
// Propagate the identity to the channel-0
|
||||
// connection so the call dispatch's
|
||||
// `resolve_identity` sees it. The
|
||||
// `ChannelsAdapter` builds `channel0_conn` fresh
|
||||
// from `channel_source` and does NOT inherit the
|
||||
// outer connection's identity — we set it here
|
||||
// so the `AccessControl` scope-gate can check it.
|
||||
if let Some(id) = _identity {
|
||||
let _ = channel0_conn.set_identity(id);
|
||||
}
|
||||
|
||||
let core = ChannelCore::new(manager, policy);
|
||||
let mut registry = OperationRegistry::new();
|
||||
register_openable(
|
||||
&core,
|
||||
Arc::clone(&backends),
|
||||
ownership.clone(),
|
||||
&mut registry,
|
||||
auth.clone(),
|
||||
)
|
||||
.expect("register_openable");
|
||||
let registry = Arc::new(registry);
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(NoopIdProvider);
|
||||
let call_connection = Arc::new(
|
||||
alkcall::protocol::connection::CallConnection::new_single_stream(
|
||||
channel0_conn,
|
||||
Arc::clone(&writer),
|
||||
),
|
||||
);
|
||||
let dp = Dispatcher::new(registry, provider);
|
||||
dp.run_loop_single_stream(call_connection, reader, writer)
|
||||
.await;
|
||||
})
|
||||
});
|
||||
let core = ChannelCore::new(manager, policy);
|
||||
let mut registry = OperationRegistry::new();
|
||||
register_openable(
|
||||
&core,
|
||||
Arc::clone(&backends),
|
||||
ownership.clone(),
|
||||
&mut registry,
|
||||
auth.clone(),
|
||||
)
|
||||
.expect("register_openable");
|
||||
let registry = Arc::new(registry);
|
||||
let provider: Arc<dyn IdentityProvider> = Arc::new(NoopIdProvider);
|
||||
let call_connection = Arc::new(
|
||||
alkcall::protocol::connection::CallConnection::new_single_stream(
|
||||
channel0_conn,
|
||||
Arc::clone(&writer),
|
||||
),
|
||||
);
|
||||
let dp = Dispatcher::new(registry, provider);
|
||||
dp.run_loop_single_stream(call_connection, reader, writer)
|
||||
.await;
|
||||
})
|
||||
});
|
||||
|
||||
let (client_end, server_end) = duplex(64 * 1024);
|
||||
let client_conn = CoreConnection::from_bidi(
|
||||
client_end,
|
||||
b"alk/channels".to_vec(),
|
||||
None,
|
||||
);
|
||||
let server_conn = CoreConnection::from_bidi(
|
||||
server_end,
|
||||
b"alk/channels".to_vec(),
|
||||
None,
|
||||
);
|
||||
let client_conn = CoreConnection::from_bidi(client_end, b"alk/channels".to_vec(), None);
|
||||
let server_conn = CoreConnection::from_bidi(server_end, b"alk/channels".to_vec(), None);
|
||||
// Set the identity on the server connection so the call
|
||||
// dispatch sees it (the ACL check runs against the
|
||||
// connection's identity, not the `install_channel_zero`
|
||||
@@ -327,10 +315,8 @@ mod tests {
|
||||
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
|
||||
let auth = AuthContext::anonymous(b"alk/channels");
|
||||
let _server_handle = tokio::spawn(async move {
|
||||
let _ = alkcall::core::types::ProtocolHandler::handle(
|
||||
&adapter, server_conn, &auth,
|
||||
)
|
||||
.await;
|
||||
let _ =
|
||||
alkcall::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
|
||||
});
|
||||
|
||||
ChannelClient::from_connection(client_conn)
|
||||
@@ -376,8 +362,7 @@ mod tests {
|
||||
.get("required")
|
||||
.and_then(|v| v.as_array())
|
||||
.expect("required array");
|
||||
let required_names: Vec<&str> =
|
||||
required.iter().filter_map(|v| v.as_str()).collect();
|
||||
let required_names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
|
||||
assert!(required_names.contains(&"carriage"));
|
||||
assert!(required_names.contains(&"backend"));
|
||||
assert!(required_names.contains(&"cmd"));
|
||||
@@ -393,8 +378,7 @@ mod tests {
|
||||
let manager = alkcall::channels::manager::ChannelManager::with_defaults(handle, None);
|
||||
let core = ChannelCore::new(manager, default_policy());
|
||||
|
||||
let backends: Arc<HashMap<String, Arc<dyn TtyBackend>>> =
|
||||
Arc::new(HashMap::new());
|
||||
let backends: Arc<HashMap<String, Arc<dyn TtyBackend>>> = Arc::new(HashMap::new());
|
||||
let mut registry = OperationRegistry::new();
|
||||
register_openable(
|
||||
&core,
|
||||
@@ -415,10 +399,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn end_to_end_open_op_returns_channel_id() {
|
||||
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 backends = Arc::new(backends);
|
||||
|
||||
let identity = Identity {
|
||||
@@ -459,10 +440,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn end_to_end_open_op_denies_without_tty_open_scope() {
|
||||
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 backends = Arc::new(backends);
|
||||
|
||||
let identity = Identity {
|
||||
@@ -488,7 +466,9 @@ mod tests {
|
||||
|
||||
let err = response.result.expect_err("open op should be denied");
|
||||
assert!(
|
||||
err.message.contains("scope") || err.code.contains("FORBIDDEN") || err.code.contains("AUTH"),
|
||||
err.message.contains("scope")
|
||||
|| err.code.contains("FORBIDDEN")
|
||||
|| err.code.contains("AUTH"),
|
||||
"error should mention scope/forbidden/auth, got code={} message={}",
|
||||
err.code,
|
||||
err.message
|
||||
@@ -514,4 +494,4 @@ mod tests {
|
||||
assert_eq!(code, 7);
|
||||
let _: Result<i32, TtyError> = Ok(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user