Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked.
definitions, deterministic BLAKE3 semantic digests and canonical registry
manifests. Existing profile persistence, wildcard evaluation, bootstrap,
socket and wire behavior remain unchanged. Closes #1233. Refs #1228.
- **Distro init can grant exactly installed capsules to an explicit target.**
`astrid --principal <operator> init --target-principal <target>
--grant-capsules` ensures the runtime daemon, verifies the operator has
`agent:modify` authority over an existing target before provisioning, and
applies the installed set through the shared `admin.agent.modify` path.
Distro capsules require a concrete released version or tag; identity and
declared version are checked before install mutation, and locks record the
version and WASM hash that actually landed. Fresh-lock reuse rehashes the
installed content blob before names become grants. Concurrent provisioning
of one target is rejected, and recovery commands preserve the operator
identity. The target defaults to the process principal when omitted; no
principal name receives special treatment.
Signed `.shuttle` grant composition remains deferred and fails explicitly.
Closes #1195.

### Changed

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

55 changes: 53 additions & 2 deletions crates/astrid-capsule-install/src/archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@
use std::path::Path;

use anyhow::{Context, bail};
use astrid_capsule::capsule::CapsuleId;
use astrid_core::dirs::AstridHome;

use astrid_core::PrincipalId;

use crate::local::{InstallOptions, InstallOutput, install_from_local_path_for_principal};
use crate::local::{
InstallOptions, InstallOutput, install_from_local_path_checked_for_principal,
install_from_local_path_for_principal,
};

/// Unpack `archive_path` (a gzipped tar) into a tempdir, then install
/// from there.
Expand Down Expand Up @@ -45,6 +49,43 @@ pub fn unpack_and_install_for_principal(
home: &AstridHome,
options: InstallOptions,
target_principal: &PrincipalId,
) -> anyhow::Result<InstallOutput> {
unpack_and_install_internal(archive_path, home, options, target_principal, None, None)
}

/// Unpack and install only when the archive manifest identity equals
/// `expected`. When `expected_version` is present, the manifest version must
/// match it too. The staged archive is inspected before install mutation.
///
/// # Errors
///
/// Returns an error for an unsafe or malformed archive, an identity or version
/// mismatch, or any failure propagated by the local installer.
pub fn unpack_and_install_checked_for_principal(
archive_path: &Path,
home: &AstridHome,
options: InstallOptions,
target_principal: &PrincipalId,
expected: &CapsuleId,
expected_version: Option<&str>,
) -> anyhow::Result<InstallOutput> {
unpack_and_install_internal(
archive_path,
home,
options,
target_principal,
Some(expected),
expected_version,
)
}

fn unpack_and_install_internal(
archive_path: &Path,
home: &AstridHome,
options: InstallOptions,
target_principal: &PrincipalId,
expected: Option<&CapsuleId>,
expected_version: Option<&str>,
) -> anyhow::Result<InstallOutput> {
let tmp_dir = tempfile::tempdir().context("failed to create temp dir for unpacking")?;
let unpack_dir = tmp_dir.path();
Expand Down Expand Up @@ -90,5 +131,15 @@ pub fn unpack_and_install_for_principal(
.with_context(|| format!("Failed to unpack file: {}", out_path.display()))?;
}

install_from_local_path_for_principal(unpack_dir, home, options, target_principal)
match expected {
Some(expected) => install_from_local_path_checked_for_principal(
unpack_dir,
home,
options,
target_principal,
expected,
expected_version,
),
None => install_from_local_path_for_principal(unpack_dir, home, options, target_principal),
}
}
90 changes: 90 additions & 0 deletions crates/astrid-capsule-install/src/checked_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
use std::fs::File;

use astrid_capsule::capsule::CapsuleId;
use astrid_core::PrincipalId;
use astrid_core::dirs::AstridHome;

use crate::{
InstallOptions, install_from_local_path_checked_for_principal, resolve_target_dir_for,
unpack_and_install_checked_for_principal,
};

fn write_manifest(dir: &std::path::Path, name: &str, version: &str) {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(
dir.join("Capsule.toml"),
format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\n"),
)
.unwrap();
}

fn existing_target(home: &AstridHome, principal: &PrincipalId, name: &str) -> std::path::PathBuf {
let target = resolve_target_dir_for(home, principal, name, false).unwrap();
std::fs::create_dir_all(&target).unwrap();
std::fs::write(target.join("marker"), b"original").unwrap();
target
}

#[test]
fn checked_local_rejects_identity_before_target_mutation() {
let temp = tempfile::tempdir().unwrap();
let home = AstridHome::from_path(temp.path().join("home"));
let principal = PrincipalId::new("alice").unwrap();
let expected = CapsuleId::new("expected").unwrap();
let source = temp.path().join("source");
write_manifest(&source, "unexpected", "1.0.0");
let target = existing_target(&home, &principal, expected.as_str());

let err = install_from_local_path_checked_for_principal(
&source,
&home,
InstallOptions::default(),
&principal,
&expected,
Some("1.0.0"),
)
.unwrap_err();

assert!(err.to_string().contains("identity mismatch"));
assert_eq!(std::fs::read(target.join("marker")).unwrap(), b"original");
assert!(
!resolve_target_dir_for(&home, &principal, "unexpected", false)
.unwrap()
.exists()
);
}

#[test]
fn checked_archive_rejects_version_before_replacing_existing_install() {
let temp = tempfile::tempdir().unwrap();
let home = AstridHome::from_path(temp.path().join("home"));
let principal = PrincipalId::new("alice").unwrap();
let expected = CapsuleId::new("expected").unwrap();
let source = temp.path().join("source");
write_manifest(&source, expected.as_str(), "2.0.0");
let target = existing_target(&home, &principal, expected.as_str());

let archive_path = temp.path().join("expected.capsule");
let encoder = flate2::write::GzEncoder::new(
File::create(&archive_path).unwrap(),
flate2::Compression::default(),
);
let mut archive = tar::Builder::new(encoder);
archive
.append_path_with_name(source.join("Capsule.toml"), "Capsule.toml")
.unwrap();
archive.into_inner().unwrap().finish().unwrap();

let err = unpack_and_install_checked_for_principal(
&archive_path,
&home,
InstallOptions::default(),
&principal,
&expected,
Some("1.0.0"),
)
.unwrap_err();

assert!(err.to_string().contains("version mismatch"));
assert_eq!(std::fs::read(target.join("marker")).unwrap(), b"original");
}
9 changes: 7 additions & 2 deletions crates/astrid-capsule-install/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ pub mod principal_introspection;
pub mod wasm;
pub mod wit;

pub use archive::{unpack_and_install, unpack_and_install_for_principal};
pub use archive::{
unpack_and_install, unpack_and_install_checked_for_principal, unpack_and_install_for_principal,
};
pub use contracts::{
CONTRACTS_WIT_BASENAME, ContractsSkew, canonical_contracts_b3, canonical_contracts_path,
contracts_pin, contracts_skew, mismatching_contracts, refresh_canonical_contracts,
Expand All @@ -81,7 +83,7 @@ pub use contracts::{
pub use copy::copy_capsule_dir;
pub use local::{
InstallOptions, InstallOutput, InstallPhase, install_from_local_path,
install_from_local_path_for_principal,
install_from_local_path_checked_for_principal, install_from_local_path_for_principal,
};
pub use manifest_check::{ExportConflict, MissingImport, check_export_conflicts, validate_imports};
pub use meta::{
Expand All @@ -94,3 +96,6 @@ pub use paths::{
};
pub use principal_introspection::materialize_principal_introspection;
pub use wit::{content_address_wit, materialize_wit_mirror};

#[cfg(test)]
mod checked_tests;
61 changes: 57 additions & 4 deletions crates/astrid-capsule-install/src/local.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
use std::path::{Path, PathBuf};

use anyhow::{Context, bail};
use astrid_capsule::capsule::CapsuleId;
use astrid_capsule::discovery::load_manifest;
use astrid_capsule::engine::wasm::host_state::LifecyclePhase;
use astrid_core::PrincipalId;
Expand Down Expand Up @@ -160,21 +161,73 @@ pub fn install_from_local_path_for_principal(
home: &AstridHome,
options: InstallOptions,
target_principal: &PrincipalId,
) -> anyhow::Result<InstallOutput> {
install_from_local_path_internal(source_dir, home, options, target_principal, None, None)
}

/// Install for an explicit principal only when the loaded manifest identity
/// equals `expected`. When `expected_version` is present, the manifest version
/// must match it too. Both comparisons happen before any install mutation.
///
/// # Errors
///
/// Returns an error when the manifest identity or expected version differs,
/// or when any ordinary install validation or filesystem operation fails.
#[allow(clippy::needless_pass_by_value)]
pub fn install_from_local_path_checked_for_principal(
source_dir: &Path,
home: &AstridHome,
options: InstallOptions,
target_principal: &PrincipalId,
expected: &CapsuleId,
expected_version: Option<&str>,
) -> anyhow::Result<InstallOutput> {
install_from_local_path_internal(
source_dir,
home,
options,
target_principal,
Some(expected),
expected_version,
)
}

#[allow(clippy::needless_pass_by_value, clippy::too_many_lines)]
fn install_from_local_path_internal(
source_dir: &Path,
home: &AstridHome,
options: InstallOptions,
target_principal: &PrincipalId,
expected: Option<&CapsuleId>,
expected_version: Option<&str>,
) -> anyhow::Result<InstallOutput> {
let manifest_path = source_dir.join("Capsule.toml");
if !manifest_path.exists() {
bail!("No Capsule.toml found in {}", source_dir.display());
}
let manifest = load_manifest(&manifest_path).context("failed to load Capsule manifest")?;
let id = manifest.package.name.clone();
let id = CapsuleId::new(manifest.package.name.clone())?;
if let Some(expected) = expected
&& id != *expected
{
bail!("capsule identity mismatch: expected '{expected}', manifest declares '{id}'");
}
let installed_version = manifest.package.version.clone();
if let Some(expected_version) = expected_version
&& installed_version != expected_version
{
bail!(
"capsule version mismatch for '{id}': expected '{expected_version}', manifest declares '{installed_version}'"
);
}

// Pre-flight checks — pure reads, no target mutation.
let export_conflicts = check_export_conflicts(&manifest)?;

// Resolve target. The parent must exist before we attempt the
// backup-rename later; create it now.
let target_dir = resolve_target_dir_for(home, target_principal, &id, options.workspace)?;
let target_dir =
resolve_target_dir_for(home, target_principal, id.as_str(), options.workspace)?;
let parent = target_dir.parent().context("target dir has no parent")?;
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
Expand Down Expand Up @@ -223,7 +276,7 @@ pub fn install_from_local_path_for_principal(

// Preserve existing .env.json (user configuration survives reinstall).
if let Some(ref backup) = backup_dir {
restore_env_from_backup_for(home, target_principal, backup, &id);
restore_env_from_backup_for(home, target_principal, backup, id.as_str());
}

// Lifecycle hook — bytes from the content store, not the target.
Expand Down Expand Up @@ -295,7 +348,7 @@ pub fn install_from_local_path_for_principal(
}

// Determine env-prompt signal for the caller.
let env_path = resolve_env_path_for(home, target_principal, &id)?;
let env_path = resolve_env_path_for(home, target_principal, id.as_str())?;
let env_needs_prompt = !manifest.env.is_empty() && !env_path.exists();

let missing_imports = if options.skip_import_check {
Expand Down
1 change: 1 addition & 0 deletions crates/astrid-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ colored = { workspace = true }
crossterm = { workspace = true }
directories = { workspace = true }
flate2 = { workspace = true }
fs2 = { workspace = true }
futures = { workspace = true }
indicatif = { workspace = true }
libc = { workspace = true }
Expand Down
50 changes: 50 additions & 0 deletions crates/astrid-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,15 @@ pub(crate) enum Commands {
/// Set a variable (repeatable): KEY=VALUE.
#[arg(long = "var", value_name = "KEY=VALUE")]
vars: Vec<String>,
/// Principal whose home and capsule access this init provisions.
/// The global `--principal` remains the authenticated operator.
#[arg(long = "target-principal", value_name = "PRINCIPAL")]
target_principal: Option<String>,
/// Grant the target principal access to every capsule the distro
/// installs (same mechanism as `agent modify --add-capsule`).
/// A distro source must resolve before initialization runs.
#[arg(long = "grant-capsules")]
grant_capsules: bool,
},

/// View resolved configuration, edit it in `$EDITOR`, or print paths.
Expand Down Expand Up @@ -645,6 +654,47 @@ mod tests {
assert_eq!(cli.principal.as_deref(), Some("operator-1"));
}

#[test]
fn grant_capsules_parsing_stays_open_for_embedding_composition() {
let cli = Cli::try_parse_from(["astrid", "init", "--grant-capsules"])
.expect("parsing stays open so an embedding layer can resolve the distro source");

assert!(matches!(
cli.command,
Some(Commands::Init {
distro: None,
grant_capsules: true,
..
})
));
}

#[test]
fn init_parses_target_separately_from_operator() {
let cli = Cli::try_parse_from([
"astrid",
"--principal",
"operator-1",
"init",
"--distro",
"./Distro.toml",
"--target-principal",
"agent-1",
"--grant-capsules",
])
.expect("operator and target principal should parse independently");

assert_eq!(cli.principal.as_deref(), Some("operator-1"));
assert!(matches!(
cli.command,
Some(Commands::Init {
target_principal: Some(ref target),
grant_capsules: true,
..
}) if target == "agent-1"
));
}

#[test]
fn global_format_does_not_collide_with_nested_format_enum() {
let cli = Cli::try_parse_from(["astrid", "keypair", "pubkey", "e2e-cli-key"])
Expand Down
Loading
Loading