use std::ffi::OsString;
use std::io::IsTerminal;
use std::process::ExitCode;
use anyhow::Result;
use crate::bootstrap;
use crate::cli::{
Cli, Commands, ConfigCommands, DistroCommands, McpCommands, SessionCommands, WitCommands,
};
use crate::commands;
use crate::commands::headless;
use crate::commands::stub::TrackingIssue;
use crate::formatter::OutputFormat;
use crate::theme::{self, print_banner};
const fn tracker_657() -> TrackingIssue {
TrackingIssue {
number: 657,
label: "CLI redesign — per-agent capsule install/list/remove",
}
}
pub(crate) async fn dispatch(cli: Cli) -> Result<ExitCode> {
if cli.emit_path {
let path = bootstrap::find_companion_binary("astrid-emit")?;
println!("{}", path.display());
return Ok(ExitCode::SUCCESS);
}
let headless_route = cli.prompt.is_some()
|| (cli.command.is_none() && !std::io::stdin().is_terminal())
|| matches!(cli.command, Some(Commands::Run(_)));
let run_auto_approve = matches!(
&cli.command,
Some(Commands::Run(args)) if args.auto_approve
);
ensure_headless_auto_approve_rejected(cli.auto_approve || run_auto_approve, headless_route)?;
if should_check_for_update(&cli) {
commands::self_update::print_update_banner().await;
}
let output_format = match cli.format.as_str() {
"json" => OutputFormat::Json,
_ => OutputFormat::Pretty,
};
if let Some(prompt_text) = cli.prompt {
bootstrap::ensure_global_config().await?;
if cli.snapshot_tui {
commands::headless::run_snapshot_tui(
prompt_text,
cli.session_name,
cli.tui_width,
cli.tui_height,
)
.await?;
return Ok(ExitCode::SUCCESS);
}
commands::headless::run_headless(
prompt_text,
output_format,
cli.session_name,
cli.print_session,
)
.await?;
return Ok(ExitCode::SUCCESS);
}
if cli.command.is_none() && !std::io::stdin().is_terminal() {
bootstrap::ensure_global_config().await?;
let mut stdin_text = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut stdin_text)?;
if !stdin_text.is_empty() {
commands::headless::run_headless(
stdin_text,
output_format,
cli.session_name,
cli.print_session,
)
.await?;
return Ok(ExitCode::SUCCESS);
}
}
dispatch_subcommand(cli.command, output_format).await
}
fn ensure_headless_auto_approve_rejected(auto_approve: bool, headless_route: bool) -> Result<()> {
if auto_approve && headless_route {
anyhow::bail!(headless::AUTO_APPROVE_UNSUPPORTED_MESSAGE);
}
Ok(())
}
fn should_check_for_update(cli: &Cli) -> bool {
cli.prompt.is_none()
&& !matches!(
cli.command,
Some(
Commands::Update(_)
| Commands::Hook(_)
| Commands::Completions(_)
| Commands::Mcp { .. }
| Commands::Init { offline: true, .. }
| Commands::Distro {
command: DistroCommands::Apply { offline: true, .. }
}
)
)
}
#[allow(
clippy::too_many_lines,
reason = "top-level subcommand dispatch is one linear match over every CLI verb; \
each arm already delegates to a dispatch_* helper, so splitting further \
would scatter the routing without reducing complexity"
)]
async fn dispatch_subcommand(
command: Option<Commands>,
output_format: OutputFormat,
) -> Result<ExitCode> {
match command {
Some(Commands::Chat { session }) => {
if output_format == OutputFormat::Json {
print_banner();
}
bootstrap::ensure_global_config().await?;
let workspace = std::env::current_dir().ok();
bootstrap::run_or_connect(session, workspace, output_format).await?;
Ok(ExitCode::SUCCESS)
},
None => {
if output_format == OutputFormat::Json {
print_banner();
}
bootstrap::ensure_global_config().await?;
let workspace = std::env::current_dir().ok();
bootstrap::run_or_connect(None, workspace, output_format).await?;
Ok(ExitCode::SUCCESS)
},
Some(Commands::Run(args)) => commands::run::run(args).await,
Some(Commands::Agent { command }) => commands::agent::run(command).await,
Some(Commands::Group { command }) => commands::group::run(command).await,
Some(Commands::Caps { command }) => commands::caps::run(command).await,
Some(Commands::Quota { command }) => commands::quota::run(command).await,
Some(Commands::Invite { command }) => commands::invite::run(command).await,
Some(Commands::Keypair { command }) => commands::keypair::run(command),
Some(Commands::PairDevice { command }) => commands::pair_device::run(command).await,
Some(Commands::Secret { command }) => commands::secret::run(command).await,
Some(Commands::Voucher { command }) => commands::voucher::run(command),
Some(Commands::Trust { command }) => commands::trust::run(command),
Some(Commands::Audit(args)) => commands::audit::run(&args).await,
Some(Commands::Hook(args)) => commands::hook::run(args).await,
Some(Commands::Budget { command }) => commands::budget::run(command),
Some(Commands::Build {
path,
output,
project_type,
from_mcp_json,
}) => {
eprintln!(
"{}",
theme::Theme::warning(
"`astrid build` is deprecated; use `astrid capsule build` instead."
)
);
commands::capsule::build::run(
path.as_deref(),
output.as_deref(),
project_type.as_deref(),
from_mcp_json.as_deref(),
)
},
Some(Commands::Init {
distro,
yes,
offline,
allow_unsigned,
accept_new_key,
vars,
target_principal,
grant_capsules,
}) => {
let distro = resolve_init_distro(distro)?;
let opts = commands::init::InitOpts {
yes,
offline,
allow_unsigned,
accept_new_key,
vars: commands::init::parse_cli_vars(&vars)?,
target_principal: target_principal
.map(astrid_core::PrincipalId::new)
.transpose()?
.unwrap_or_else(crate::principal::current),
grant_capsules,
require_signed: false,
selected_capsules: Vec::new(),
};
let _daemon_lease = commands::init::run_init(&distro, &opts).await?;
commands::self_update::ensure_path_setup()?;
Ok(ExitCode::SUCCESS)
},
Some(Commands::Capsule { command }) => dispatch_capsule(command).await,
Some(Commands::Mcp { command }) => dispatch_mcp(command).await,
Some(Commands::Distro { command }) => dispatch_distro(command).await,
Some(Commands::Wit { command }) => dispatch_wit(&command),
Some(Commands::Gc(args)) => commands::gc::run(&args),
Some(Commands::Config { command }) => dispatch_config(command),
Some(Commands::Session { command }) => dispatch_session(command),
Some(Commands::Start { ephemeral }) => {
if ephemeral {
commands::daemon::ensure_daemon("start").await?;
} else {
commands::daemon::handle_start().await?;
}
Ok(ExitCode::SUCCESS)
},
Some(Commands::Status) => {
commands::daemon::handle_status(output_format).await?;
Ok(ExitCode::SUCCESS)
},
Some(Commands::Stop) => {
commands::daemon::handle_stop().await?;
Ok(ExitCode::SUCCESS)
},
Some(Commands::Restart) => {
commands::daemon::validate_runtime_admission()?;
commands::restart::run().await
},
Some(Commands::Storage { command }) => commands::storage::run(command),
Some(Commands::Logs(args)) => commands::logs::run(&args).await,
Some(Commands::Ps(args)) => commands::ps::run(args).await,
Some(Commands::Top(args)) => commands::top::run(args).await,
Some(Commands::Who(args)) => commands::who::run(args).await,
Some(Commands::Doctor(args)) => commands::doctor::run(args).await,
Some(Commands::Setup(args)) => commands::setup::run(&args),
Some(Commands::Version(args)) => commands::version::run(&args),
Some(Commands::Completions(args)) => commands::completions::run(&args),
Some(Commands::Update(args)) => {
commands::self_update::run_self_update(args).await?;
Ok(ExitCode::SUCCESS)
},
Some(Commands::External(tokens)) => dispatch_root_shorthand(tokens).await,
}
}
fn resolve_init_distro(requested: Option<String>) -> Result<String> {
resolve_init_distro_with(requested, std::env::var_os("ASTRID_ENFORCED_DISTRO"))
}
fn resolve_init_distro_with(
requested: Option<String>,
enforced: Option<OsString>,
) -> Result<String> {
let Some(enforced) = enforced else {
return non_empty_distro_source(requested).ok_or_else(|| {
anyhow::anyhow!(
"astrid init requires --distro <@owner/repo, URL, local Distro.toml, or .shuttle> unless ASTRID_ENFORCED_DISTRO is set by an embedding launcher; Astrid Runtime does not choose a product distro"
)
});
};
let enforced = enforced.into_string().map_err(|_| {
anyhow::anyhow!("ASTRID_ENFORCED_DISTRO must contain a valid UTF-8 distro source")
})?;
if enforced.is_empty() {
anyhow::bail!("ASTRID_ENFORCED_DISTRO must not be empty");
}
if requested.is_some() {
anyhow::bail!(
"astrid init cannot override the operator-enforced distro in ASTRID_ENFORCED_DISTRO"
);
}
Ok(enforced)
}
fn non_empty_distro_source(source: Option<String>) -> Option<String> {
source.filter(|source| !source.is_empty())
}
async fn dispatch_root_shorthand(tokens: Vec<String>) -> Result<ExitCode> {
let verb = tokens.first().map_or("", String::as_str);
let builtins = builtin_subcommand_names();
if let Some(suggestion) = commands::verb_suggest::nearest_builtin(verb, &builtins) {
eprintln!(
"{}",
theme::Theme::error(&format!(
"unrecognized subcommand '{verb}'\n\n\tDid you mean '{suggestion}'?"
))
);
return Ok(ExitCode::from(2));
}
commands::capsule_verb::run_external(tokens).await
}
fn builtin_subcommand_names() -> Vec<String> {
use clap::CommandFactory;
use std::collections::BTreeSet;
Cli::command()
.get_subcommands()
.flat_map(|s| {
std::iter::once(s.get_name().to_string())
.chain(s.get_all_aliases().map(std::string::ToString::to_string))
})
.filter(|n| !n.is_empty())
.collect::<BTreeSet<String>>()
.into_iter()
.collect()
}
async fn dispatch_capsule(command: crate::cli::CapsuleCommands) -> Result<ExitCode> {
use crate::cli::CapsuleCommands;
match command {
CapsuleCommands::New(args) => commands::capsule::new::run(&args),
CapsuleCommands::Install {
source,
capsule,
workspace,
yes,
approve_untrusted,
vars,
} => {
commands::capsule::install::install_capsule_with_options(
&source,
capsule.as_deref(),
workspace,
yes,
approve_untrusted,
&vars,
)
.await?;
Ok(ExitCode::SUCCESS)
},
CapsuleCommands::Update {
target,
workspace,
approve_untrusted,
} => {
commands::capsule::install::update_capsule(
target.as_deref(),
workspace,
approve_untrusted,
)
.await?;
Ok(ExitCode::SUCCESS)
},
CapsuleCommands::List { verbose } => {
commands::capsule::list::list_capsules(verbose).await?;
Ok(ExitCode::SUCCESS)
},
CapsuleCommands::Remove {
name,
workspace,
force,
purge,
} => dispatch_capsule_remove(name, workspace, force, purge).await,
CapsuleCommands::Tree | CapsuleCommands::Deps => {
commands::capsule::deps::show_tree().await?;
Ok(ExitCode::SUCCESS)
},
CapsuleCommands::Build {
path,
output,
project_type,
from_mcp_json,
} => commands::capsule::build::run(
path.as_deref(),
output.as_deref(),
project_type.as_deref(),
from_mcp_json.as_deref(),
),
CapsuleCommands::Check { path } => commands::capsule::check::run(path.as_deref()),
CapsuleCommands::Config(args) => commands::capsule::config::run(&args),
CapsuleCommands::Show(args) => commands::capsule::show::run(&args).await,
CapsuleCommands::Run {
provider,
verb,
args,
} => commands::capsule_verb::run_explicit(provider, verb, args).await,
CapsuleCommands::External(tokens) => commands::capsule_verb::run_external(tokens).await,
}
}
async fn dispatch_capsule_remove(
name: String,
workspace: bool,
force: bool,
purge: bool,
) -> Result<ExitCode> {
if workspace {
commands::capsule::remove::validate_capsule_removal(&name, true, force)?;
commands::capsule::remove::remove_capsule(&name, true, force, purge)?;
return Ok(ExitCode::SUCCESS);
}
let mut client = crate::socket_client::connect_kernel_for_workspace(None).await?;
match client
.request(astrid_core::kernel_api::KernelRequest::RemoveCapsule {
id: name.clone(),
force,
purge,
})
.await?
{
astrid_core::kernel_api::KernelResponse::Success(_) => {
if purge {
let principal = crate::principal::current();
let entries =
commands::capsule::install_headless::list_env_entries(&principal, &name)?;
for entry in entries {
if matches!(entry.scope, astrid_core::kernel_api::EnvStorageScope::Agent) {
commands::capsule::install_headless::delete_env_entry(
&principal, &name, &entry.key, entry.kind,
)?;
}
}
}
eprintln!("Removed '{name}'.");
},
astrid_core::kernel_api::KernelResponse::Error(message) => {
anyhow::bail!("daemon rejected capsule removal: {message}");
},
other => anyhow::bail!("unexpected daemon response: {other:?}"),
}
Ok(ExitCode::SUCCESS)
}
async fn dispatch_mcp(command: McpCommands) -> Result<ExitCode> {
if !matches!(command, McpCommands::Gc) {
commands::daemon::validate_runtime_admission()?;
}
match command {
McpCommands::Serve {
workspace,
request_timeout: _,
} => commands::mcp::serve(None, workspace.as_deref()).await,
McpCommands::Attach { workspace } => {
commands::mcp::attach(None, workspace.as_deref()).await
},
McpCommands::Gateway => commands::mcp::gateway(None).await,
McpCommands::Http {
listen,
token_file,
workspace,
} => commands::mcp::http::run(listen, token_file.as_deref(), workspace.as_deref()).await,
McpCommands::Ready { format } => commands::mcp::ready(None, &format).await,
McpCommands::Gc => commands::mcp::gc(),
}
}
async fn dispatch_distro(command: DistroCommands) -> Result<ExitCode> {
match command {
DistroCommands::Apply {
name,
agent,
yes,
offline,
allow_unsigned,
accept_new_key,
vars,
capsules,
} => {
if agent.is_some() {
return Ok(commands::stub::deferred(
"distro apply -a <agent>",
&[tracker_657()],
));
}
if allow_unsigned {
anyhow::bail!(
"astrid distro apply requires a signed Distro; --allow-unsigned is not acceptance"
);
}
let distro = non_empty_distro_source(name).ok_or_else(|| {
anyhow::anyhow!(
"astrid distro apply requires an explicit distro source: @owner/repo, URL, local Distro.toml, or .shuttle; Astrid Runtime does not choose a product distro"
)
})?;
commands::init::reject_filtered_shuttle(&distro, &capsules)?;
let filtered = !capsules.is_empty();
let opts = commands::init::InitOpts {
yes,
offline,
allow_unsigned,
accept_new_key,
vars: commands::init::parse_cli_vars(&vars)?,
target_principal: crate::principal::current(),
grant_capsules: false,
require_signed: true,
selected_capsules: capsules,
};
let _daemon_lease = commands::init::run_init(&distro, &opts).await?;
if !filtered && apply_self_grant_required(&distro) {
commands::init::apply_self_grant(&opts.target_principal).await?;
}
Ok(ExitCode::SUCCESS)
},
DistroCommands::Show { agent } => {
if agent.is_some() {
return Ok(commands::stub::deferred(
"distro show -a <agent>",
&[tracker_657()],
));
}
eprintln!(
"{}",
theme::Theme::info(
"`distro show` is not yet wired — use the daemon-owned distro provenance via an authenticated admin client"
)
);
Ok(ExitCode::from(2))
},
DistroCommands::Update { agent, force } => {
if agent.is_some() {
return Ok(commands::stub::deferred(
"distro update -a <agent>",
&[tracker_657()],
));
}
let _ = force; eprintln!(
"{}",
theme::Theme::info(
"`distro update` reapplies the active distro — for now: astrid distro apply"
)
);
Ok(ExitCode::from(2))
},
DistroCommands::Seal {
distro,
output,
key,
} => {
commands::distro::seal::run_seal(&distro, &output, &key).await?;
Ok(ExitCode::SUCCESS)
},
}
}
fn apply_self_grant_required(distro_source: &str) -> bool {
!distro_source.ends_with(".shuttle")
}
fn dispatch_wit(command: &WitCommands) -> Result<ExitCode> {
match command {
WitCommands::Gc { force } => {
eprintln!(
"{}",
theme::Theme::warning("`astrid wit gc` is deprecated; use `astrid gc` instead.")
);
commands::wit::gc(*force)?;
Ok(ExitCode::SUCCESS)
},
}
}
fn dispatch_config(command: ConfigCommands) -> Result<ExitCode> {
match command {
ConfigCommands::Show { format, section } => {
commands::config::show_config(&format, section.as_deref())?;
Ok(ExitCode::SUCCESS)
},
ConfigCommands::Edit => {
commands::config::edit_config()?;
Ok(ExitCode::SUCCESS)
},
ConfigCommands::Path => {
commands::config::show_paths()?;
Ok(ExitCode::SUCCESS)
},
}
}
fn dispatch_session(command: SessionCommands) -> Result<ExitCode> {
match command {
SessionCommands::List => {
commands::sessions::list_sessions()?;
Ok(ExitCode::SUCCESS)
},
SessionCommands::Delete { id } => {
commands::sessions::delete_session(&id)?;
Ok(ExitCode::SUCCESS)
},
SessionCommands::Show { id } => {
commands::sessions::session_info(&id)?;
Ok(ExitCode::SUCCESS)
},
SessionCommands::Info { id } => {
eprintln!(
"{}",
theme::Theme::warning(
"`astrid session info` is deprecated; use `astrid session show` instead."
)
);
commands::sessions::session_info(&id)?;
Ok(ExitCode::SUCCESS)
},
}
}
#[cfg(test)]
mod tests {
use clap::Parser;
use super::*;
#[test]
fn headless_auto_approve_is_rejected_before_execution() {
let error = ensure_headless_auto_approve_rejected(true, true)
.expect_err("prompt automation must be rejected");
assert!(error.to_string().contains("unsupported"));
assert!(error.to_string().contains("correlated"));
}
#[test]
fn non_headless_auto_approve_is_not_rejected_by_the_headless_guard() {
ensure_headless_auto_approve_rejected(true, false).unwrap();
}
#[test]
fn root_auto_approve_before_the_run_subcommand_is_rejected() {
for flag in ["--yes", "--yolo", "--autonomous"] {
let cli = Cli::try_parse_from(["astrid", flag, "run", "hello"])
.expect("root-level automation parses before subcommand dispatch");
assert!(cli.auto_approve);
assert!(matches!(cli.command, Some(Commands::Run(_))));
let headless_route = cli.prompt.is_some()
|| (cli.command.is_none() && !std::io::stdin().is_terminal())
|| matches!(cli.command, Some(Commands::Run(_)));
ensure_headless_auto_approve_rejected(cli.auto_approve, headless_route)
.expect_err("run automation must be rejected");
}
}
#[test]
fn offline_commands_never_check_for_updates() {
for args in [
vec![
"astrid",
"init",
"--distro",
"/tmp/Distro.toml",
"--offline",
],
vec!["astrid", "distro", "apply", "/tmp/Distro.toml", "--offline"],
] {
let cli = Cli::try_parse_from(args).expect("parse offline command");
assert!(!should_check_for_update(&cli));
}
let cli = Cli::try_parse_from(["astrid", "init", "--distro", "@astrid-runtime/example"])
.expect("parse online init");
assert!(should_check_for_update(&cli));
}
#[test]
fn builtin_names_include_invocable_aliases() {
let names = builtin_subcommand_names();
assert!(
names.iter().any(|n| n == "self-update"),
"production builtin harvest must include the `self-update` alias \
(aliased on `update`); got {names:?}"
);
assert!(
names.iter().any(|n| n == "update"),
"production builtin harvest must include the `update` primary; got {names:?}"
);
}
#[test]
fn builtin_names_drop_empty_catch_all_placeholder() {
let names = builtin_subcommand_names();
assert!(
names.iter().all(|n| !n.is_empty()),
"harvested builtin names must never contain the empty catch-all placeholder"
);
}
#[test]
fn alias_near_miss_is_caught_against_production_builtins() {
let builtins = builtin_subcommand_names();
let refs: Vec<&str> = builtins.iter().map(String::as_str).collect();
assert_eq!(
commands::verb_suggest::nearest_builtin("self-updte", &refs),
Some("self-update"),
"a one-character slip off the `self-update` alias must suggest it; \
this fails if the production harvest drops aliases"
);
}
#[tokio::test]
async fn init_without_a_distro_never_selects_a_product_default() {
for distro in [None, Some(String::new())] {
let error = dispatch_subcommand(
Some(Commands::Init {
distro,
yes: false,
offline: false,
allow_unsigned: false,
accept_new_key: false,
vars: Vec::new(),
target_principal: None,
grant_capsules: false,
}),
OutputFormat::Pretty,
)
.await
.expect_err("standalone init must require a non-empty explicit distro");
assert_eq!(
error.to_string(),
"astrid init requires --distro <@owner/repo, URL, local Distro.toml, or .shuttle> unless ASTRID_ENFORCED_DISTRO is set by an embedding launcher; Astrid Runtime does not choose a product distro"
);
}
}
#[test]
fn distro_resolution_without_a_source_never_selects_a_product_default() {
let error = resolve_init_distro_with(None, None)
.expect_err("standalone init must require an explicit distro");
assert_eq!(
error.to_string(),
"astrid init requires --distro <@owner/repo, URL, local Distro.toml, or .shuttle> unless ASTRID_ENFORCED_DISTRO is set by an embedding launcher; Astrid Runtime does not choose a product distro"
);
}
#[test]
fn operator_enforced_distro_cannot_be_overridden_by_the_cli() {
assert_eq!(
resolve_init_distro_with(Some("@example/other".to_string()), None)
.expect("standalone explicit distro should remain valid"),
"@example/other"
);
assert_eq!(
resolve_init_distro_with(None, Some(OsString::from("/opt/product/Distro.toml")))
.expect("operator distro should satisfy init"),
"/opt/product/Distro.toml"
);
let error = resolve_init_distro_with(
Some("@example/other".to_string()),
Some(OsString::from("/opt/product/Distro.toml")),
)
.expect_err("CLI must not override an operator-enforced distro");
assert_eq!(
error.to_string(),
"astrid init cannot override the operator-enforced distro in ASTRID_ENFORCED_DISTRO"
);
}
#[test]
fn operator_distro_and_targeted_capsule_grants_compose() {
let cli = Cli::try_parse_from([
"astrid",
"--principal",
"operator-1",
"init",
"--target-principal",
"agent-1",
"--grant-capsules",
])
.expect("an operator may supply the distro outside Astrid's CLI arguments");
assert_eq!(cli.principal.as_deref(), Some("operator-1"));
let Some(Commands::Init {
distro,
target_principal,
grant_capsules,
..
}) = cli.command
else {
panic!("expected init command");
};
assert_eq!(
resolve_init_distro_with(distro, Some(OsString::from("/opt/product/Distro.toml")),)
.expect("operator-enforced distro should satisfy init"),
"/opt/product/Distro.toml"
);
assert_eq!(target_principal.as_deref(), Some("agent-1"));
assert!(grant_capsules);
}
#[test]
fn malformed_operator_enforced_distro_fails_closed() {
let empty = resolve_init_distro_with(None, Some(OsString::new()))
.expect_err("empty enforced distro must fail");
assert_eq!(
empty.to_string(),
"ASTRID_ENFORCED_DISTRO must not be empty"
);
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
let invalid = resolve_init_distro_with(
None,
Some(OsString::from_vec(vec![
b'd', b'i', b's', b't', b'r', b'o', 0xff,
])),
)
.expect_err("non-UTF-8 enforced distro must fail");
assert_eq!(
invalid.to_string(),
"ASTRID_ENFORCED_DISTRO must contain a valid UTF-8 distro source"
);
}
}
#[tokio::test]
async fn distro_apply_without_a_source_never_selects_a_product_default() {
for name in [None, Some(String::new())] {
let error = dispatch_distro(DistroCommands::Apply {
name,
agent: None,
yes: false,
offline: false,
allow_unsigned: false,
accept_new_key: false,
vars: Vec::new(),
capsules: Vec::new(),
})
.await
.expect_err("standalone distro apply must require a non-empty explicit distro");
assert_eq!(
error.to_string(),
"astrid distro apply requires an explicit distro source: @owner/repo, URL, local Distro.toml, or .shuttle; Astrid Runtime does not choose a product distro"
);
}
}
#[tokio::test]
async fn distro_apply_rejects_unsigned_acceptance_before_install() {
let error = dispatch_distro(DistroCommands::Apply {
name: Some("/tmp/product.shuttle".into()),
agent: None,
yes: true,
offline: true,
allow_unsigned: true,
accept_new_key: false,
vars: Vec::new(),
capsules: Vec::new(),
})
.await
.expect_err("--allow-unsigned must not make Distro apply acceptance-capable");
assert!(
error
.to_string()
.contains("--allow-unsigned is not acceptance")
);
}
#[test]
fn self_grant_follows_source_manifests_but_not_shuttle_packages() {
assert!(apply_self_grant_required("/tmp/product/Distro.toml"));
assert!(!apply_self_grant_required("/tmp/product.shuttle"));
}
#[tokio::test]
async fn distro_apply_rejects_filtered_shuttle_before_install() {
let error = dispatch_distro(DistroCommands::Apply {
name: Some("/tmp/product.shuttle".into()),
agent: None,
yes: true,
offline: true,
allow_unsigned: false,
accept_new_key: false,
vars: Vec::new(),
capsules: vec!["aos-mcp".into()],
})
.await
.expect_err("filtered apply must not use shuttle sources");
assert!(
error
.to_string()
.contains("not supported for .shuttle sources"),
"got: {error:#}"
);
}
}