use super::{
AttachExitReason, ConnectionContext, KernelClientFactory, SSH_RECONNECT_MAX_ATTEMPTS,
connect_attach_target_with_kernel, connect_with_context, map_cli_client_error,
reconnect_backoff_ms, run_session_attach_with_client,
};
use crate::connection::ConnectionPolicyScope;
use crate::pane_runtime_client::BmuxPaneRuntimeClientExt;
use anyhow::{Context, Result};
use bmux_config::{BmuxConfig, ConfigPaths, KioskProfileConfig, KioskRole, KioskSandboxMode};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::Write as _;
use std::future::Future;
use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::time::Duration;
use uuid::Uuid;
#[derive(Debug, Clone, serde::Serialize)]
struct ResolvedKioskProfile {
name: String,
session: Option<String>,
target: Option<String>,
role: KioskRole,
ssh_user: String,
allow_detach: bool,
token_ttl_secs: u64,
one_shot: bool,
sandbox: KioskSandboxMode,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
struct KioskTokenStore {
tokens: BTreeMap<String, KioskTokenRecord>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct KioskTokenRecord {
token_id: String,
profile: String,
session: Option<String>,
role: KioskRole,
one_shot: bool,
issued_at_unix: i64,
expires_at_unix: i64,
used_at_unix: Option<i64>,
revoked_at_unix: Option<i64>,
secret_sha256_hex: String,
}
pub(super) fn run_kiosk_status(as_json: bool) -> Result<u8> {
let config = BmuxConfig::load()?;
let profiles = resolve_profiles(&config);
if as_json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"enabled": config.kiosk.defaults.enabled,
"profiles": profiles,
}))
.context("failed serializing kiosk status json")?
);
return Ok(0);
}
println!(
"kiosk: {}",
if config.kiosk.defaults.enabled {
"enabled"
} else {
"disabled"
}
);
println!("profiles: {}", profiles.len());
for profile in profiles.values() {
println!(
"- {} ssh_user={} role={:?} one_shot={} ttl={}s target={} session={}",
profile.name,
profile.ssh_user,
profile.role,
profile.one_shot,
profile.token_ttl_secs,
profile.target.as_deref().unwrap_or("local"),
profile.session.as_deref().unwrap_or("-")
);
}
Ok(0)
}
pub(super) fn run_kiosk_issue_token(
profile: &str,
session: Option<&str>,
ttl_secs: Option<u64>,
one_shot: bool,
multi_use: bool,
) -> Result<u8> {
let config = BmuxConfig::load()?;
let profiles = resolve_profiles(&config);
let resolved = profiles.get(profile).ok_or_else(|| {
anyhow::anyhow!(
"unknown kiosk profile '{profile}' (known: {})",
profiles.keys().cloned().collect::<Vec<_>>().join(", ")
)
})?;
let now = current_unix_timestamp();
let effective_one_shot = if one_shot {
true
} else if multi_use {
false
} else {
resolved.one_shot
};
let ttl = ttl_secs.unwrap_or(resolved.token_ttl_secs);
let expires_at_unix = now.saturating_add(i64::try_from(ttl).unwrap_or(i64::MAX));
let token_id = Uuid::new_v4().to_string();
let token_secret = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
let secret_sha256_hex = sha256_hex(token_secret.as_bytes());
let token_value = format!("k1.{token_id}.{token_secret}");
let mut store = load_token_store()?;
store.tokens.insert(
token_id.clone(),
KioskTokenRecord {
token_id: token_id.clone(),
profile: profile.to_string(),
session: session
.map(ToOwned::to_owned)
.or_else(|| resolved.session.clone()),
role: resolved.role,
one_shot: effective_one_shot,
issued_at_unix: now,
expires_at_unix,
used_at_unix: None,
revoked_at_unix: None,
secret_sha256_hex,
},
);
save_token_store(&store)?;
println!("kiosk token issued");
println!("profile: {profile}");
println!("token_id: {token_id}");
println!("expires_at_unix: {expires_at_unix}");
println!("one_shot: {effective_one_shot}");
println!();
println!("token: {token_value}");
println!();
println!(
"authorized_keys example:\nrestrict,command=\"bmux kiosk attach {profile} --token {token_value}\" <public-key>"
);
Ok(0)
}
pub(super) fn run_kiosk_revoke_token(token_id: &str) -> Result<u8> {
let mut store = load_token_store()?;
let Some(record) = store.tokens.get_mut(token_id) else {
anyhow::bail!("kiosk token id not found: {token_id}");
};
record.revoked_at_unix = Some(current_unix_timestamp());
save_token_store(&store)?;
println!("revoked kiosk token: {token_id}");
Ok(0)
}
pub(super) async fn run_kiosk_attach(
profile: &str,
token: &str,
connection_context: ConnectionContext<'_>,
) -> Result<u8> {
let config = BmuxConfig::load()?;
let profiles = resolve_profiles(&config);
let resolved = profiles.get(profile).ok_or_else(|| {
anyhow::anyhow!(
"unknown kiosk profile '{profile}' (known: {})",
profiles.keys().cloned().collect::<Vec<_>>().join(", ")
)
})?;
let (token_id, token_secret) = parse_raw_token(token)?;
let mut store = load_token_store()?;
let record = store
.tokens
.get_mut(token_id)
.ok_or_else(|| anyhow::anyhow!("unknown kiosk token id: {token_id}"))?;
if record.profile != profile {
anyhow::bail!("token profile mismatch");
}
if record.revoked_at_unix.is_some() {
anyhow::bail!("kiosk token is revoked");
}
if record.expires_at_unix < current_unix_timestamp() {
anyhow::bail!("kiosk token is expired");
}
if record.one_shot && record.used_at_unix.is_some() {
anyhow::bail!("kiosk token already used");
}
let provided_hash = sha256_hex(token_secret.as_bytes());
if provided_hash != record.secret_sha256_hex {
anyhow::bail!("invalid kiosk token secret");
}
record.used_at_unix = Some(current_unix_timestamp());
let session = record.session.clone();
save_token_store(&store)?;
let effective_target =
resolve_kiosk_attach_target(profile, resolved.target.as_deref(), connection_context)?;
let (client, kernel_client_factory) = if let Some(target) = effective_target.as_deref() {
connect_attach_target_with_kernel(target, "bmux-cli-kiosk-attach").await?
} else {
let client = connect_with_context(
ConnectionPolicyScope::Normal,
"bmux-cli-kiosk-attach",
connection_context,
)
.await?;
(client, None)
};
run_kiosk_attach_with_reconnect(
client,
kernel_client_factory,
effective_target.as_deref(),
resolved.allow_detach,
session.as_deref(),
)
.await
}
fn resolve_kiosk_attach_target(
profile: &str,
profile_target: Option<&str>,
connection_context: ConnectionContext<'_>,
) -> Result<Option<String>> {
let target_override = connection_context
.target_override
.map(str::trim)
.filter(|value| !value.is_empty());
if let Some(pinned_target) = profile_target {
if let Some(override_target) = target_override
&& override_target != pinned_target
{
anyhow::bail!(
"kiosk profile '{profile}' pins target '{pinned_target}' and cannot be overridden by --target '{override_target}'"
);
}
return Ok(Some(pinned_target.to_string()));
}
Ok(target_override.map(ToString::to_string))
}
async fn run_kiosk_attach_with_reconnect(
client: bmux_client::BmuxClient,
kernel_client_factory: Option<KernelClientFactory>,
reconnect_target: Option<&str>,
allow_detach: bool,
session: Option<&str>,
) -> Result<u8> {
let mut runner = LiveKioskAttachRunner {
client: Some(client),
kernel_client_factory,
};
run_kiosk_attach_with_reconnect_loop(&mut runner, reconnect_target, allow_detach, session).await
}
trait KioskAttachLoopDriver {
fn set_attach_policy(
&mut self,
allow_detach: bool,
) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>>;
fn run_attach<'a>(
&'a mut self,
session: Option<&'a str>,
) -> Pin<Box<dyn Future<Output = Result<super::AttachRunOutcome>> + Send + 'a>>;
fn reconnect<'a>(
&'a mut self,
target: &'a str,
) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
fn sleep_backoff(&mut self, backoff: Duration)
-> Pin<Box<dyn Future<Output = ()> + Send + '_>>;
}
struct LiveKioskAttachRunner {
client: Option<bmux_client::BmuxClient>,
kernel_client_factory: Option<KernelClientFactory>,
}
impl KioskAttachLoopDriver for LiveKioskAttachRunner {
fn set_attach_policy(
&mut self,
allow_detach: bool,
) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
Box::pin(async move {
let client = self
.client
.as_mut()
.ok_or_else(|| anyhow::anyhow!("kiosk attach client unavailable"))?;
client
.set_attach_policy(allow_detach)
.await
.map_err(map_cli_client_error)
})
}
fn run_attach<'a>(
&'a mut self,
session: Option<&'a str>,
) -> Pin<Box<dyn Future<Output = Result<super::AttachRunOutcome>> + Send + 'a>> {
Box::pin(async move {
let client = self
.client
.take()
.ok_or_else(|| anyhow::anyhow!("kiosk attach client unavailable"))?;
let kernel_client_factory = self.kernel_client_factory.take();
run_session_attach_with_client(client, session, None, false, kernel_client_factory)
.await
})
}
fn reconnect<'a>(
&'a mut self,
target: &'a str,
) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
Box::pin(async move {
let (new_client, new_kernel_factory) =
connect_attach_target_with_kernel(target, "bmux-cli-kiosk-attach-reconnect")
.await?;
self.client = Some(new_client);
self.kernel_client_factory = new_kernel_factory;
Ok(())
})
}
fn sleep_backoff(
&mut self,
backoff: Duration,
) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
Box::pin(async move {
tokio::time::sleep(backoff).await;
})
}
}
async fn run_kiosk_attach_with_reconnect_loop(
runner: &mut impl KioskAttachLoopDriver,
reconnect_target: Option<&str>,
allow_detach: bool,
session: Option<&str>,
) -> Result<u8> {
let mut attempt = 0usize;
loop {
runner.set_attach_policy(allow_detach).await?;
let outcome = runner.run_attach(session).await?;
if outcome.exit_reason != AttachExitReason::StreamClosed {
return Ok(outcome.status_code);
}
let Some(target) = reconnect_target else {
return Ok(outcome.status_code);
};
if attempt >= SSH_RECONNECT_MAX_ATTEMPTS {
println!(
"kiosk remote connection closed; giving up after {SSH_RECONNECT_MAX_ATTEMPTS} reconnect attempts"
);
return Ok(1);
}
attempt = attempt.saturating_add(1);
let backoff = Duration::from_millis(reconnect_backoff_ms(attempt));
println!(
"kiosk remote connection closed; reconnecting to '{target}' (attempt {attempt}/{}) in {}ms...",
SSH_RECONNECT_MAX_ATTEMPTS,
backoff.as_millis()
);
runner.sleep_backoff(backoff).await;
runner.reconnect(target).await?;
}
}
pub(super) fn run_kiosk_init(
profiles: &[String],
all_profiles: bool,
dry_run: bool,
yes: bool,
) -> Result<u8> {
let config = BmuxConfig::load()?;
let resolved_profiles = resolve_profiles(&config);
let selected_names = select_profiles(&resolved_profiles, profiles, all_profiles)?;
let selected = selected_names
.iter()
.filter_map(|name| resolved_profiles.get(name).cloned())
.collect::<Vec<_>>();
let paths = ConfigPaths::default();
let wrapper_dir = config
.kiosk
.files
.wrapper_dir
.unwrap_or_else(|| paths.config_dir.join("kiosk/wrappers"));
let sshd_include_path = config
.kiosk
.files
.sshd_include_path
.unwrap_or_else(|| paths.config_dir.join("kiosk/sshd_config.generated"));
let mut write_plan = Vec::new();
let include = render_sshd_include(&selected, &wrapper_dir);
write_plan.push((sshd_include_path, include));
for profile in &selected {
let script_path = wrapper_dir.join(format!("{}.sh", profile.name));
write_plan.push((script_path, render_wrapper_script(profile)));
}
println!("kiosk init plan:");
for (path, _) in &write_plan {
println!("- {}", path.display());
}
if dry_run {
println!("dry-run: no files were written");
return Ok(0);
}
if !yes && !confirm_apply()? {
println!("kiosk init cancelled");
return Ok(1);
}
for (path, content) in write_plan {
write_text_file(&path, &content)?;
if is_shell_script_path(&path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&path)?.permissions();
perms.set_mode(0o750);
std::fs::set_permissions(&path, perms).with_context(|| {
format!("failed setting executable mode on {}", path.display())
})?;
}
}
}
println!("kiosk init complete");
Ok(0)
}
pub(super) fn run_kiosk_ssh_print_config(profiles: &[String], all_profiles: bool) -> Result<u8> {
let config = BmuxConfig::load()?;
let resolved_profiles = resolve_profiles(&config);
let selected_names = select_profiles(&resolved_profiles, profiles, all_profiles)?;
let selected = selected_names
.iter()
.filter_map(|name| resolved_profiles.get(name).cloned())
.collect::<Vec<_>>();
let paths = ConfigPaths::default();
let wrapper_dir = config
.kiosk
.files
.wrapper_dir
.unwrap_or_else(|| paths.config_dir.join("kiosk/wrappers"));
print!("{}", render_sshd_include(&selected, &wrapper_dir));
Ok(0)
}
fn resolve_profiles(config: &BmuxConfig) -> BTreeMap<String, ResolvedKioskProfile> {
let mut profiles = BTreeMap::new();
if config.kiosk.profiles.is_empty() {
profiles.insert(
"default".to_string(),
apply_profile_overrides(
"default",
&config.kiosk.defaults,
&KioskProfileConfig::default(),
),
);
return profiles;
}
for (name, profile) in &config.kiosk.profiles {
profiles.insert(
name.clone(),
apply_profile_overrides(name, &config.kiosk.defaults, profile),
);
}
profiles
}
fn apply_profile_overrides(
name: &str,
defaults: &bmux_config::KioskDefaultsConfig,
profile: &KioskProfileConfig,
) -> ResolvedKioskProfile {
ResolvedKioskProfile {
name: name.to_string(),
session: profile.session.clone(),
target: profile.target.clone(),
role: profile.role.unwrap_or(defaults.role),
ssh_user: profile
.ssh_user
.clone()
.unwrap_or_else(|| defaults.ssh_user.clone()),
allow_detach: profile.allow_detach.unwrap_or(defaults.allow_detach),
token_ttl_secs: profile.token_ttl_secs.unwrap_or(defaults.token_ttl_secs),
one_shot: profile.one_shot.unwrap_or(defaults.one_shot),
sandbox: profile.sandbox.unwrap_or(defaults.sandbox),
}
}
fn select_profiles(
resolved: &BTreeMap<String, ResolvedKioskProfile>,
profiles: &[String],
all_profiles: bool,
) -> Result<Vec<String>> {
if all_profiles || profiles.is_empty() {
return Ok(resolved.keys().cloned().collect());
}
let available: BTreeSet<&str> = resolved.keys().map(String::as_str).collect();
for profile in profiles {
if !available.contains(profile.as_str()) {
anyhow::bail!(
"unknown kiosk profile '{profile}' (known: {})",
resolved.keys().cloned().collect::<Vec<_>>().join(", ")
);
}
}
Ok(profiles.to_vec())
}
fn render_sshd_include(profiles: &[ResolvedKioskProfile], wrapper_dir: &Path) -> String {
let mut by_user: BTreeMap<String, Vec<&ResolvedKioskProfile>> = BTreeMap::new();
for profile in profiles {
by_user
.entry(profile.ssh_user.clone())
.or_default()
.push(profile);
}
let mut out = String::new();
out.push_str("# Generated by bmux kiosk init\n");
out.push_str("# Include this from sshd_config with: Include /path/to/this/file\n\n");
for (ssh_user, user_profiles) in by_user {
let primary = user_profiles
.iter()
.find(|profile| profile.name == "default")
.copied()
.unwrap_or(user_profiles[0]);
let wrapper = wrapper_dir.join(format!("{}.sh", primary.name));
let _ = writeln!(out, "Match User {ssh_user}");
out.push_str(" PasswordAuthentication no\n");
out.push_str(" KbdInteractiveAuthentication no\n");
out.push_str(" PubkeyAuthentication yes\n");
out.push_str(" AuthenticationMethods publickey\n");
out.push_str(" PermitTTY yes\n");
out.push_str(" PermitUserRC no\n");
out.push_str(" X11Forwarding no\n");
out.push_str(" AllowTcpForwarding no\n");
out.push_str(" AllowAgentForwarding no\n");
out.push_str(" PermitTunnel no\n");
out.push_str(" GatewayPorts no\n");
let _ = writeln!(out, " ForceCommand {}", wrapper.display());
if user_profiles.len() > 1 {
let profile_names = user_profiles
.iter()
.map(|profile| profile.name.as_str())
.collect::<Vec<_>>()
.join(", ");
let _ = writeln!(
out,
" # multiple profiles share this user: {profile_names} (ForceCommand uses '{}')",
primary.name
);
}
out.push('\n');
}
out
}
fn render_wrapper_script(profile: &ResolvedKioskProfile) -> String {
format!(
"#!/bin/sh\nset -eu\nif [ -z \"${{BMUX_KIOSK_TOKEN:-}}\" ]; then\n printf '%s\\n' \"BMUX_KIOSK_TOKEN is required\" >&2\n exit 64\nfi\nexec bmux kiosk attach {profile} --token \"$BMUX_KIOSK_TOKEN\"\n",
profile = profile.name
)
}
fn confirm_apply() -> Result<bool> {
if !io::stdin().is_terminal() {
anyhow::bail!("kiosk init requires --yes in non-interactive mode");
}
print!("Apply kiosk init changes? [y/N]: ");
io::stdout()
.flush()
.context("failed flushing kiosk init prompt")?;
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.context("failed reading kiosk init confirmation")?;
let normalized = input.trim().to_ascii_lowercase();
Ok(normalized == "y" || normalized == "yes")
}
fn write_text_file(path: &Path, content: &str) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed creating parent directory {}", parent.display()))?;
}
std::fs::write(path, content).with_context(|| format!("failed writing {}", path.display()))
}
fn is_shell_script_path(path: &Path) -> bool {
path.extension().is_some_and(|ext| ext == "sh")
}
fn parse_raw_token(token: &str) -> Result<(&str, &str)> {
let mut parts = token.splitn(3, '.');
let prefix = parts.next().unwrap_or_default();
let token_id = parts.next().unwrap_or_default();
let secret = parts.next().unwrap_or_default();
if prefix != "k1" || token_id.is_empty() || secret.is_empty() {
anyhow::bail!("invalid kiosk token format");
}
Ok((token_id, secret))
}
fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
let digest = hasher.finalize();
let mut output = String::with_capacity(digest.len() * 2);
for byte in digest {
let _ = write!(output, "{byte:02x}");
}
output
}
fn load_token_store() -> Result<KioskTokenStore> {
let path = token_store_path(&ConfigPaths::default());
if !path.exists() {
return Ok(KioskTokenStore::default());
}
let bytes =
std::fs::read(&path).with_context(|| format!("failed reading {}", path.display()))?;
serde_json::from_slice::<KioskTokenStore>(&bytes)
.with_context(|| format!("failed parsing {}", path.display()))
}
fn save_token_store(store: &KioskTokenStore) -> Result<()> {
let path = token_store_path(&ConfigPaths::default());
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed creating token store dir {}", parent.display()))?;
}
let encoded =
serde_json::to_vec_pretty(store).context("failed serializing kiosk token store")?;
std::fs::write(&path, encoded).with_context(|| format!("failed writing {}", path.display()))
}
fn token_store_path(paths: &ConfigPaths) -> PathBuf {
paths.state_dir().join("runtime").join("kiosk-tokens.json")
}
fn current_unix_timestamp() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| {
i64::try_from(duration.as_secs()).unwrap_or(i64::MAX)
})
}
#[cfg(test)]
mod tests {
use super::{
KioskAttachLoopDriver, resolve_kiosk_attach_target, run_kiosk_attach_with_reconnect_loop,
};
use crate::connection::ConnectionContext;
use crate::runtime::{AttachExitReason, AttachRunOutcome};
use anyhow::Result;
use std::collections::VecDeque;
use std::future::Future;
use std::pin::Pin;
use std::time::Duration;
struct MockKioskAttachRunner {
outcomes: VecDeque<AttachRunOutcome>,
set_policy_calls: usize,
reconnect_calls: usize,
sleep_calls: usize,
}
impl MockKioskAttachRunner {
fn with_outcomes(outcomes: Vec<AttachRunOutcome>) -> Self {
Self {
outcomes: outcomes.into(),
set_policy_calls: 0,
reconnect_calls: 0,
sleep_calls: 0,
}
}
}
impl KioskAttachLoopDriver for MockKioskAttachRunner {
fn set_attach_policy(
&mut self,
_allow_detach: bool,
) -> Pin<Box<dyn Future<Output = Result<()>> + Send + '_>> {
self.set_policy_calls = self.set_policy_calls.saturating_add(1);
Box::pin(async { Ok(()) })
}
fn run_attach<'a>(
&'a mut self,
_session: Option<&'a str>,
) -> Pin<Box<dyn Future<Output = Result<AttachRunOutcome>> + Send + 'a>> {
let outcome = self
.outcomes
.pop_front()
.ok_or_else(|| anyhow::anyhow!("test runner has no attach outcomes remaining"));
Box::pin(async move { outcome })
}
fn reconnect<'a>(
&'a mut self,
_target: &'a str,
) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
self.reconnect_calls = self.reconnect_calls.saturating_add(1);
Box::pin(async { Ok(()) })
}
fn sleep_backoff(
&mut self,
_backoff: Duration,
) -> Pin<Box<dyn Future<Output = ()> + Send + '_>> {
self.sleep_calls = self.sleep_calls.saturating_add(1);
Box::pin(async {})
}
}
#[test]
fn kiosk_attach_target_uses_profile_pin_when_present() {
let resolved =
resolve_kiosk_attach_target("demo", Some("prod-ssh"), ConnectionContext::new(None))
.expect("target resolution should succeed");
assert_eq!(resolved.as_deref(), Some("prod-ssh"));
}
#[test]
fn kiosk_attach_target_rejects_conflicting_cli_override() {
let error = resolve_kiosk_attach_target(
"demo",
Some("prod-ssh"),
ConnectionContext::new(Some("staging-ssh")),
)
.expect_err("conflicting override should fail");
assert!(
error
.to_string()
.contains("cannot be overridden by --target 'staging-ssh'")
);
}
#[test]
fn kiosk_attach_target_uses_cli_override_without_profile_pin() {
let resolved = resolve_kiosk_attach_target(
"demo",
None,
ConnectionContext::new(Some("tls://demo.example.com")),
)
.expect("target resolution should succeed");
assert_eq!(resolved.as_deref(), Some("tls://demo.example.com"));
}
#[tokio::test]
async fn reconnect_loop_reapplies_policy_after_stream_closed() {
let mut runner = MockKioskAttachRunner::with_outcomes(vec![
AttachRunOutcome {
status_code: 1,
exit_reason: AttachExitReason::StreamClosed,
},
AttachRunOutcome {
status_code: 0,
exit_reason: AttachExitReason::Detached,
},
]);
let status = run_kiosk_attach_with_reconnect_loop(
&mut runner,
Some("ssh://demo@host"),
false,
Some("session-a"),
)
.await
.expect("loop should succeed");
assert_eq!(status, 0);
assert_eq!(runner.set_policy_calls, 2);
assert_eq!(runner.reconnect_calls, 1);
assert_eq!(runner.sleep_calls, 1);
}
#[tokio::test]
async fn reconnect_loop_does_not_retry_without_target() {
let mut runner = MockKioskAttachRunner::with_outcomes(vec![AttachRunOutcome {
status_code: 7,
exit_reason: AttachExitReason::StreamClosed,
}]);
let status = run_kiosk_attach_with_reconnect_loop(&mut runner, None, true, None)
.await
.expect("loop should succeed");
assert_eq!(status, 7);
assert_eq!(runner.set_policy_calls, 1);
assert_eq!(runner.reconnect_calls, 0);
assert_eq!(runner.sleep_calls, 0);
}
}