From 7a73579494018869daad98eb4bb65318efec5d27 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Tue, 14 Jul 2026 22:00:41 +0400 Subject: [PATCH 01/20] feat(core): inject a neutral workspace layout --- CHANGELOG.md | 7 + Cargo.lock | 1 + crates/astrid-capsule-install/src/archive.rs | 118 +++++++- .../astrid-capsule-install/src/contracts.rs | 3 +- crates/astrid-capsule-install/src/lib.rs | 23 +- crates/astrid-capsule-install/src/local.rs | 177 ++++++++++-- .../src/manifest_check.rs | 78 +++++- crates/astrid-capsule-install/src/meta.rs | 78 +++++- crates/astrid-capsule-install/src/paths.rs | 78 +++++- crates/astrid-capsule-install/src/wit.rs | 5 +- crates/astrid-capsule/src/discovery.rs | 52 +++- crates/astrid-cli/src/bootstrap.rs | 23 +- crates/astrid-cli/src/cli.rs | 37 +++ .../astrid-cli/src/commands/capsule/deps.rs | 4 +- .../src/commands/capsule/install.rs | 44 ++- .../src/commands/capsule/install_update.rs | 78 +++++- .../astrid-cli/src/commands/capsule/list.rs | 5 +- .../src/commands/capsule/live_load.rs | 11 +- .../astrid-cli/src/commands/capsule/meta.rs | 4 +- .../astrid-cli/src/commands/capsule/remove.rs | 24 +- .../astrid-cli/src/commands/capsule_verb.rs | 23 +- crates/astrid-cli/src/commands/config.rs | 13 +- crates/astrid-cli/src/commands/daemon.rs | 75 ++++- crates/astrid-cli/src/commands/headless.rs | 4 +- crates/astrid-cli/src/commands/init.rs | 5 +- crates/astrid-cli/src/commands/mcp/mod.rs | 4 +- .../src/commands/mcp/session_guard.rs | 3 +- crates/astrid-cli/src/commands/mcp/watch.rs | 3 +- crates/astrid-cli/src/commands/wit.rs | 55 +++- crates/astrid-cli/src/main.rs | 5 + crates/astrid-cli/src/socket_client.rs | 9 + crates/astrid-cli/src/workspace_layout.rs | 13 + crates/astrid-config/Cargo.toml | 1 + crates/astrid-config/src/defaults.toml | 2 +- crates/astrid-config/src/lib.rs | 37 ++- crates/astrid-config/src/loader.rs | 63 ++++- crates/astrid-config/src/merge/types.rs | 4 +- crates/astrid-config/src/show.rs | 45 ++- crates/astrid-core/src/dirs.rs | 261 ++++++++++++++++-- crates/astrid-core/src/dirs_tests.rs | 102 +++++++ crates/astrid-daemon/src/lib.rs | 48 +++- crates/astrid-gateway/src/lib.rs | 92 +++++- .../src/routes/capsule_sources.rs | 17 +- crates/astrid-gateway/src/routes/mod.rs | 117 +++++++- crates/astrid-gateway/src/routes/models.rs | 66 ++++- crates/astrid-gateway/src/routes/sessions.rs | 131 ++++++++- crates/astrid-hooks/src/discovery.rs | 71 ++++- .../src/kernel_router/install.rs | 22 +- crates/astrid-kernel/src/kernel_router/mod.rs | 8 +- crates/astrid-kernel/src/lib.rs | 117 +++++++- crates/astrid-kernel/src/socket.rs | 60 +++- 51 files changed, 2083 insertions(+), 243 deletions(-) create mode 100644 crates/astrid-cli/src/workspace_layout.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ee1dd1893..337877bdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,6 +86,13 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. duplicate, or SHA-only manifests. Existing v0.9.x installations can still cross the boundary through the compatibility manifest. External protocol requirements remain unchanged. Closes #1249. +- **Project runtime state now uses one validated workspace layout.** The CLI + and daemon default to `.astrid`, while distributions can select another safe + relative directory name through `--workspace-state-dir` or + `ASTRID_WORKSPACE_STATE_DIR`. Config, capsule installation and discovery, + kernel boot, gateway source checks, hooks, and WIT garbage collection share + the selected layout and never scan both project roots. CLI uplinks reject a + daemon booted for a different project or layout. - **Runtime E2E now stages the pinned Unicity AOS monorepo.** The workflow preserves the AOS Cargo workspace outside the core checkout and supplies diff --git a/Cargo.lock b/Cargo.lock index f002359ec..be42294c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -541,6 +541,7 @@ dependencies = [ name = "astrid-config" version = "0.9.4" dependencies = [ + "astrid-core", "directories", "serde", "serde_json", diff --git a/crates/astrid-capsule-install/src/archive.rs b/crates/astrid-capsule-install/src/archive.rs index eae99f350..cc2ba702f 100644 --- a/crates/astrid-capsule-install/src/archive.rs +++ b/crates/astrid-capsule-install/src/archive.rs @@ -11,13 +11,14 @@ use std::path::Path; use anyhow::{Context, bail}; use astrid_capsule::capsule::CapsuleId; -use astrid_core::dirs::AstridHome; +use astrid_core::dirs::{AstridHome, WorkspaceLayout}; use astrid_core::PrincipalId; use crate::local::{ - InstallOptions, InstallOutput, install_from_local_path_checked_for_principal, - install_from_local_path_for_principal, + ExpectedCapsuleIdentity, InstallOptions, InstallOutput, InstallWorkspace, + install_from_local_path_checked_for_principal_in_workspace, + install_from_local_path_for_principal_in_workspace, }; /// Unpack `archive_path` (a gzipped tar) into a tempdir, then install @@ -35,11 +36,22 @@ pub fn unpack_and_install( home: &AstridHome, options: InstallOptions, ) -> anyhow::Result { - unpack_and_install_for_principal( + unpack_and_install_with_layout(archive_path, home, options, &WorkspaceLayout::default()) +} + +/// Unpack and install using an explicit workspace layout. +pub fn unpack_and_install_with_layout( + archive_path: &Path, + home: &AstridHome, + options: InstallOptions, + workspace_layout: &WorkspaceLayout, +) -> anyhow::Result { + unpack_and_install_for_principal_with_layout( archive_path, home, options, &crate::paths::install_principal(), + workspace_layout, ) } @@ -50,7 +62,54 @@ pub fn unpack_and_install_for_principal( options: InstallOptions, target_principal: &PrincipalId, ) -> anyhow::Result { - unpack_and_install_internal(archive_path, home, options, target_principal, None, None) + unpack_and_install_for_principal_with_layout( + archive_path, + home, + options, + target_principal, + &WorkspaceLayout::default(), + ) +} + +/// Unpack and install for an explicit principal and workspace layout. +pub fn unpack_and_install_for_principal_with_layout( + archive_path: &Path, + home: &AstridHome, + options: InstallOptions, + target_principal: &PrincipalId, + workspace_layout: &WorkspaceLayout, +) -> anyhow::Result { + let workspace_root = std::env::current_dir().ok(); + unpack_and_install_for_principal_in_workspace( + archive_path, + home, + options, + target_principal, + workspace_root.as_deref(), + workspace_layout, + ) +} + +/// Unpack and install with explicit principal and workspace inputs. +pub fn unpack_and_install_for_principal_in_workspace( + archive_path: &Path, + home: &AstridHome, + options: InstallOptions, + target_principal: &PrincipalId, + workspace_root: Option<&Path>, + workspace_layout: &WorkspaceLayout, +) -> anyhow::Result { + unpack_and_install_internal( + archive_path, + home, + options, + target_principal, + InstallWorkspace { + root: workspace_root, + layout: workspace_layout, + }, + None, + ) } /// Unpack and install only when the archive manifest identity equals @@ -69,23 +128,51 @@ pub fn unpack_and_install_checked_for_principal( expected: &CapsuleId, expected_version: Option<&str>, ) -> anyhow::Result { - unpack_and_install_internal( + unpack_and_install_checked_for_principal_with_layout( archive_path, home, options, target_principal, - Some(expected), + expected, expected_version, + &WorkspaceLayout::default(), ) } -fn unpack_and_install_internal( +/// Checked archive install for an explicit principal and workspace layout. +pub fn unpack_and_install_checked_for_principal_with_layout( archive_path: &Path, home: &AstridHome, options: InstallOptions, target_principal: &PrincipalId, - expected: Option<&CapsuleId>, + expected: &CapsuleId, expected_version: Option<&str>, + workspace_layout: &WorkspaceLayout, +) -> anyhow::Result { + let workspace_root = std::env::current_dir().ok(); + unpack_and_install_internal( + archive_path, + home, + options, + target_principal, + InstallWorkspace { + root: workspace_root.as_deref(), + layout: workspace_layout, + }, + Some(ExpectedCapsuleIdentity { + id: expected, + version: expected_version, + }), + ) +} + +fn unpack_and_install_internal( + archive_path: &Path, + home: &AstridHome, + options: InstallOptions, + target_principal: &PrincipalId, + workspace: InstallWorkspace<'_>, + expected: Option>, ) -> anyhow::Result { let tmp_dir = tempfile::tempdir().context("failed to create temp dir for unpacking")?; let unpack_dir = tmp_dir.path(); @@ -132,14 +219,21 @@ fn unpack_and_install_internal( } match expected { - Some(expected) => install_from_local_path_checked_for_principal( + Some(expected) => install_from_local_path_checked_for_principal_in_workspace( unpack_dir, home, options, target_principal, + workspace, expected, - expected_version, ), - None => install_from_local_path_for_principal(unpack_dir, home, options, target_principal), + None => install_from_local_path_for_principal_in_workspace( + unpack_dir, + home, + options, + target_principal, + workspace.root, + workspace.layout, + ), } } diff --git a/crates/astrid-capsule-install/src/contracts.rs b/crates/astrid-capsule-install/src/contracts.rs index 0f0c111c5..159782bf9 100644 --- a/crates/astrid-capsule-install/src/contracts.rs +++ b/crates/astrid-capsule-install/src/contracts.rs @@ -300,8 +300,7 @@ fn is_blake3_pin(pin: &str) -> bool { /// daemon can warn rather than silently skip the baseline. Per-entry errors /// are logged and skipped so one bad entry can't abort the whole scan. /// -/// Reads only the injected `home` — never the process environment or a -/// workspace `.astrid/` (unlike [`scan_installed_capsules`](crate::scan_installed_capsules)) — +/// Reads only the injected `home`, never process environment or project state, /// so it is safe on the fail-closed daemon boot path. fn daemon_fleet_contracts_pin(home: &AstridHome) -> anyhow::Result> { let principal = crate::paths::install_principal(); diff --git a/crates/astrid-capsule-install/src/lib.rs b/crates/astrid-capsule-install/src/lib.rs index e5d8404c6..bef02f470 100644 --- a/crates/astrid-capsule-install/src/lib.rs +++ b/crates/astrid-capsule-install/src/lib.rs @@ -73,7 +73,10 @@ pub mod wasm; pub mod wit; pub use archive::{ - unpack_and_install, unpack_and_install_checked_for_principal, unpack_and_install_for_principal, + unpack_and_install, unpack_and_install_checked_for_principal, + unpack_and_install_checked_for_principal_with_layout, unpack_and_install_for_principal, + unpack_and_install_for_principal_in_workspace, unpack_and_install_for_principal_with_layout, + unpack_and_install_with_layout, }; pub use contracts::{ CONTRACTS_WIT_BASENAME, ContractsSkew, canonical_contracts_b3, canonical_contracts_path, @@ -83,16 +86,26 @@ pub use contracts::{ pub use copy::copy_capsule_dir; pub use local::{ InstallOptions, InstallOutput, InstallPhase, install_from_local_path, - install_from_local_path_checked_for_principal, install_from_local_path_for_principal, + install_from_local_path_checked_for_principal, + install_from_local_path_checked_for_principal_with_layout, + install_from_local_path_for_principal, install_from_local_path_for_principal_in_workspace, + install_from_local_path_for_principal_with_layout, install_from_local_path_with_layout, +}; +pub use manifest_check::{ + ExportConflict, MissingImport, check_export_conflicts, check_export_conflicts_in_workspace, + check_export_conflicts_with_layout, validate_imports, validate_imports_in_workspace, + validate_imports_with_layout, }; -pub use manifest_check::{ExportConflict, MissingImport, check_export_conflicts, validate_imports}; pub use meta::{ CapsuleLocation, CapsuleMeta, InstalledCapsule, read_meta, scan_installed_capsules, - scan_installed_capsules_in_home_for, write_meta, + scan_installed_capsules_in_home_for, scan_installed_capsules_in_home_for_in_workspace, + scan_installed_capsules_in_home_for_with_layout, scan_installed_capsules_in_home_with_layout, + scan_installed_capsules_with_layout, write_meta, }; pub use paths::{ resolve_env_path, resolve_env_path_for, resolve_target_dir, resolve_target_dir_for, - restore_env_from_backup, restore_env_from_backup_for, + resolve_target_dir_for_in_workspace, resolve_target_dir_for_with_layout, + resolve_target_dir_with_layout, restore_env_from_backup, restore_env_from_backup_for, }; pub use principal_introspection::materialize_principal_introspection; pub use wit::{content_address_wit, materialize_wit_mirror}; diff --git a/crates/astrid-capsule-install/src/local.rs b/crates/astrid-capsule-install/src/local.rs index 5d814dc37..6b2dc7b9b 100644 --- a/crates/astrid-capsule-install/src/local.rs +++ b/crates/astrid-capsule-install/src/local.rs @@ -36,24 +36,39 @@ use astrid_capsule::capsule::CapsuleId; use astrid_capsule::discovery::load_manifest; use astrid_capsule::engine::wasm::host_state::LifecyclePhase; use astrid_core::PrincipalId; -use astrid_core::dirs::AstridHome; +use astrid_core::dirs::{AstridHome, WorkspaceLayout}; use astrid_events::EventBus; use crate::contracts::seed_canonical_contracts_if_absent; use crate::copy::copy_capsule_dir; use crate::lifecycle::run_lifecycle; use crate::manifest_check::{ - ExportConflict, MissingImport, check_export_conflicts, validate_imports, + ExportConflict, MissingImport, check_export_conflicts_in_workspace, + validate_imports_in_workspace, }; use crate::meta::{CapsuleMeta, read_meta, write_meta}; -use crate::paths::{resolve_env_path_for, resolve_target_dir_for, restore_env_from_backup_for}; +use crate::paths::{ + resolve_env_path_for, resolve_target_dir_for_in_workspace, restore_env_from_backup_for, +}; use crate::wasm::{WasmAddressed, content_address_wasm}; use crate::wit::{content_address_wit, materialize_wit_mirror, version_map_to_strings}; +#[derive(Clone, Copy)] +pub(crate) struct InstallWorkspace<'a> { + pub(crate) root: Option<&'a Path>, + pub(crate) layout: &'a WorkspaceLayout, +} + +#[derive(Clone, Copy)] +pub(crate) struct ExpectedCapsuleIdentity<'a> { + pub(crate) id: &'a CapsuleId, + pub(crate) version: Option<&'a str>, +} + /// Knobs passed to [`install_from_local_path`]. #[derive(Default)] pub struct InstallOptions { - /// Install into `/.astrid/capsules/` instead of the + /// Install into the selected project's capsules directory instead of the /// principal's home directory. pub workspace: bool, /// The source string the user originally typed (e.g. a GitHub @@ -146,11 +161,23 @@ pub fn install_from_local_path( home: &AstridHome, options: InstallOptions, ) -> anyhow::Result { - install_from_local_path_for_principal( + install_from_local_path_with_layout(source_dir, home, options, &WorkspaceLayout::default()) +} + +/// Install a capsule using an explicit workspace layout. +#[allow(clippy::needless_pass_by_value, clippy::too_many_lines)] +pub fn install_from_local_path_with_layout( + source_dir: &Path, + home: &AstridHome, + options: InstallOptions, + workspace_layout: &WorkspaceLayout, +) -> anyhow::Result { + install_from_local_path_for_principal_with_layout( source_dir, home, options, &crate::paths::install_principal(), + workspace_layout, ) } @@ -162,7 +189,56 @@ pub fn install_from_local_path_for_principal( options: InstallOptions, target_principal: &PrincipalId, ) -> anyhow::Result { - install_from_local_path_internal(source_dir, home, options, target_principal, None, None) + install_from_local_path_for_principal_with_layout( + source_dir, + home, + options, + target_principal, + &WorkspaceLayout::default(), + ) +} + +/// Install a capsule for an explicit principal and workspace layout. +#[allow(clippy::needless_pass_by_value, clippy::too_many_lines)] +pub fn install_from_local_path_for_principal_with_layout( + source_dir: &Path, + home: &AstridHome, + options: InstallOptions, + target_principal: &PrincipalId, + workspace_layout: &WorkspaceLayout, +) -> anyhow::Result { + let workspace_root = std::env::current_dir().ok(); + install_from_local_path_for_principal_in_workspace( + source_dir, + home, + options, + target_principal, + workspace_root.as_deref(), + workspace_layout, + ) +} + +/// Install a capsule with explicit principal and workspace inputs. +#[allow(clippy::needless_pass_by_value, clippy::too_many_lines)] +pub fn install_from_local_path_for_principal_in_workspace( + source_dir: &Path, + home: &AstridHome, + options: InstallOptions, + target_principal: &PrincipalId, + workspace_root: Option<&Path>, + workspace_layout: &WorkspaceLayout, +) -> anyhow::Result { + install_from_local_path_internal( + source_dir, + home, + options, + target_principal, + InstallWorkspace { + root: workspace_root, + layout: workspace_layout, + }, + None, + ) } /// Install for an explicit principal only when the loaded manifest identity @@ -181,14 +257,62 @@ pub fn install_from_local_path_checked_for_principal( target_principal: &PrincipalId, expected: &CapsuleId, expected_version: Option<&str>, +) -> anyhow::Result { + install_from_local_path_checked_for_principal_with_layout( + source_dir, + home, + options, + target_principal, + expected, + expected_version, + &WorkspaceLayout::default(), + ) +} + +/// Checked install for an explicit principal and workspace layout. +#[allow(clippy::needless_pass_by_value)] +pub fn install_from_local_path_checked_for_principal_with_layout( + source_dir: &Path, + home: &AstridHome, + options: InstallOptions, + target_principal: &PrincipalId, + expected: &CapsuleId, + expected_version: Option<&str>, + workspace_layout: &WorkspaceLayout, +) -> anyhow::Result { + let workspace_root = std::env::current_dir().ok(); + install_from_local_path_checked_for_principal_in_workspace( + source_dir, + home, + options, + target_principal, + InstallWorkspace { + root: workspace_root.as_deref(), + layout: workspace_layout, + }, + ExpectedCapsuleIdentity { + id: expected, + version: expected_version, + }, + ) +} + +#[allow(clippy::needless_pass_by_value)] +pub(crate) fn install_from_local_path_checked_for_principal_in_workspace( + source_dir: &Path, + home: &AstridHome, + options: InstallOptions, + target_principal: &PrincipalId, + workspace: InstallWorkspace<'_>, + expected: ExpectedCapsuleIdentity<'_>, ) -> anyhow::Result { install_from_local_path_internal( source_dir, home, options, target_principal, + workspace, Some(expected), - expected_version, ) } @@ -198,8 +322,8 @@ fn install_from_local_path_internal( home: &AstridHome, options: InstallOptions, target_principal: &PrincipalId, - expected: Option<&CapsuleId>, - expected_version: Option<&str>, + workspace: InstallWorkspace<'_>, + expected: Option>, ) -> anyhow::Result { let manifest_path = source_dir.join("Capsule.toml"); if !manifest_path.exists() { @@ -208,12 +332,15 @@ fn install_from_local_path_internal( let manifest = load_manifest(&manifest_path).context("failed to load Capsule manifest")?; let id = CapsuleId::new(manifest.package.name.clone())?; if let Some(expected) = expected - && id != *expected + && id != *expected.id { - bail!("capsule identity mismatch: expected '{expected}', manifest declares '{id}'"); + bail!( + "capsule identity mismatch: expected '{}', manifest declares '{id}'", + expected.id + ); } let installed_version = manifest.package.version.clone(); - if let Some(expected_version) = expected_version + if let Some(expected_version) = expected.and_then(|expected| expected.version) && installed_version != expected_version { bail!( @@ -222,12 +349,24 @@ fn install_from_local_path_internal( } // Pre-flight checks — pure reads, no target mutation. - let export_conflicts = check_export_conflicts(&manifest)?; + let export_conflicts = check_export_conflicts_in_workspace( + &manifest, + home, + target_principal, + workspace.root, + workspace.layout, + )?; // 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.as_str(), options.workspace)?; + let target_dir = resolve_target_dir_for_in_workspace( + home, + target_principal, + id.as_str(), + options.workspace, + workspace.root, + workspace.layout, + )?; 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()))?; @@ -354,7 +493,13 @@ fn install_from_local_path_internal( let missing_imports = if options.skip_import_check { Vec::new() } else { - validate_imports(&manifest) + validate_imports_in_workspace( + &manifest, + home, + target_principal, + workspace.root, + workspace.layout, + ) }; // Cleanup the backup — success path. diff --git a/crates/astrid-capsule-install/src/manifest_check.rs b/crates/astrid-capsule-install/src/manifest_check.rs index d85c3a951..d92303b4c 100644 --- a/crates/astrid-capsule-install/src/manifest_check.rs +++ b/crates/astrid-capsule-install/src/manifest_check.rs @@ -7,8 +7,10 @@ use anyhow::Context; use astrid_capsule::manifest::CapsuleManifest; +use astrid_core::dirs::WorkspaceLayout; +use std::path::Path; -use crate::meta::scan_installed_capsules; +use crate::meta::scan_installed_capsules_in_home_for_in_workspace; /// An unsatisfied non-optional import surfaced by [`validate_imports`]. #[derive(Debug, Clone)] @@ -26,10 +28,45 @@ pub struct MissingImport { /// are silently skipped. Returns the missing ones — the caller decides /// whether to log, error, or render in a UI. pub fn validate_imports(manifest: &CapsuleManifest) -> Vec { + validate_imports_with_layout(manifest, &WorkspaceLayout::default()) +} + +/// Validate imports using an explicit workspace layout. +pub fn validate_imports_with_layout( + manifest: &CapsuleManifest, + workspace_layout: &WorkspaceLayout, +) -> Vec { + let home = match astrid_core::dirs::AstridHome::resolve() { + Ok(home) => home, + Err(_) => return Vec::new(), + }; + let workspace_root = std::env::current_dir().ok(); + validate_imports_in_workspace( + manifest, + &home, + &crate::paths::install_principal(), + workspace_root.as_deref(), + workspace_layout, + ) +} + +/// Validate imports using explicit home and workspace inputs. +pub fn validate_imports_in_workspace( + manifest: &CapsuleManifest, + home: &astrid_core::dirs::AstridHome, + principal: &astrid_core::PrincipalId, + workspace_root: Option<&Path>, + workspace_layout: &WorkspaceLayout, +) -> Vec { if !manifest.has_imports() { return Vec::new(); } - let Ok(all_capsules) = scan_installed_capsules() else { + let Ok(all_capsules) = scan_installed_capsules_in_home_for_in_workspace( + home, + principal, + workspace_root, + workspace_layout, + ) else { return Vec::new(); }; @@ -75,12 +112,45 @@ pub struct ExportConflict { /// dispatcher decides who handles a given call. The caller may want /// to log this for operator visibility. pub fn check_export_conflicts(manifest: &CapsuleManifest) -> anyhow::Result> { + check_export_conflicts_with_layout(manifest, &WorkspaceLayout::default()) +} + +/// Detect export conflicts using an explicit workspace layout. +pub fn check_export_conflicts_with_layout( + manifest: &CapsuleManifest, + workspace_layout: &WorkspaceLayout, +) -> anyhow::Result> { + let home = astrid_core::dirs::AstridHome::resolve() + .context("failed to resolve Astrid home directory")?; + let workspace_root = std::env::current_dir().ok(); + check_export_conflicts_in_workspace( + manifest, + &home, + &crate::paths::install_principal(), + workspace_root.as_deref(), + workspace_layout, + ) +} + +/// Detect export conflicts using explicit home and workspace inputs. +pub fn check_export_conflicts_in_workspace( + manifest: &CapsuleManifest, + home: &astrid_core::dirs::AstridHome, + principal: &astrid_core::PrincipalId, + workspace_root: Option<&Path>, + workspace_layout: &WorkspaceLayout, +) -> anyhow::Result> { if !manifest.has_exports() { return Ok(Vec::new()); } - let all_capsules = scan_installed_capsules() - .context("failed to scan installed capsules for export conflict check")?; + let all_capsules = scan_installed_capsules_in_home_for_in_workspace( + home, + principal, + workspace_root, + workspace_layout, + ) + .context("failed to scan installed capsules for export conflict check")?; let mut shared = Vec::new(); for (ns, name, _ver) in manifest.export_triples() { diff --git a/crates/astrid-capsule-install/src/meta.rs b/crates/astrid-capsule-install/src/meta.rs index e0f15dfcb..6c1936fe6 100644 --- a/crates/astrid-capsule-install/src/meta.rs +++ b/crates/astrid-capsule-install/src/meta.rs @@ -14,7 +14,7 @@ use std::fmt; use std::path::Path; use anyhow::Context; -use astrid_core::dirs::AstridHome; +use astrid_core::dirs::{AstridHome, WorkspaceLayout}; use serde::{Deserialize, Serialize}; /// Capsule installation metadata, persisted as `meta.json` alongside `Capsule.toml`. @@ -110,7 +110,7 @@ pub fn write_meta(target_dir: &Path, meta: &CapsuleMeta) -> anyhow::Result<()> { pub enum CapsuleLocation { /// User-level: `~/.astrid/capsules/` User, - /// Workspace-level: `.astrid/capsules/` relative to CWD + /// Workspace-level: capsules under the selected project state directory. Workspace, } @@ -136,15 +136,30 @@ pub struct InstalledCapsule { /// Scan user-level and workspace capsule directories, returning all installed /// capsules sorted alphabetically by name. pub fn scan_installed_capsules() -> anyhow::Result> { + scan_installed_capsules_with_layout(&WorkspaceLayout::default()) +} + +/// Scan installed capsules using an explicit workspace layout. +pub fn scan_installed_capsules_with_layout( + workspace_layout: &WorkspaceLayout, +) -> anyhow::Result> { let home = AstridHome::resolve().context("failed to resolve Astrid home directory")?; - scan_installed_capsules_in_home(&home) + scan_installed_capsules_in_home_with_layout(&home, workspace_layout) } /// Scan user-level and workspace capsule directories for an explicit Astrid /// home, returning all installed capsules sorted alphabetically by name. pub fn scan_installed_capsules_in_home(home: &AstridHome) -> anyhow::Result> { + scan_installed_capsules_in_home_with_layout(home, &WorkspaceLayout::default()) +} + +/// Scan installed capsules for an explicit home and workspace layout. +pub fn scan_installed_capsules_in_home_with_layout( + home: &AstridHome, + workspace_layout: &WorkspaceLayout, +) -> anyhow::Result> { let principal = crate::paths::install_principal(); - scan_installed_capsules_in_home_for(home, &principal) + scan_installed_capsules_in_home_for_with_layout(home, &principal, workspace_layout) } /// Scan user-level capsule directories for `principal` and workspace capsule @@ -153,6 +168,31 @@ pub fn scan_installed_capsules_in_home(home: &AstridHome) -> anyhow::Result anyhow::Result> { + scan_installed_capsules_in_home_for_with_layout(home, principal, &WorkspaceLayout::default()) +} + +/// Scan principal and workspace capsules using an explicit workspace layout. +pub fn scan_installed_capsules_in_home_for_with_layout( + home: &AstridHome, + principal: &astrid_core::PrincipalId, + workspace_layout: &WorkspaceLayout, +) -> anyhow::Result> { + let workspace_root = std::env::current_dir().ok(); + scan_installed_capsules_in_home_for_in_workspace( + home, + principal, + workspace_root.as_deref(), + workspace_layout, + ) +} + +/// Scan principal and workspace capsules using explicit workspace inputs. +pub fn scan_installed_capsules_in_home_for_in_workspace( + home: &AstridHome, + principal: &astrid_core::PrincipalId, + workspace_root: Option<&Path>, + workspace_layout: &WorkspaceLayout, ) -> anyhow::Result> { let mut capsules = Vec::new(); @@ -161,8 +201,8 @@ pub fn scan_installed_capsules_in_home_for( scan_dir(&principal_dir, CapsuleLocation::User, &mut capsules)?; } - if let Ok(cwd) = std::env::current_dir() { - let ws_dir = cwd.join(".astrid").join("capsules"); + if let Some(workspace_root) = workspace_root { + let ws_dir = workspace_layout.capsules_dir(workspace_root); if ws_dir.is_dir() { scan_dir(&ws_dir, CapsuleLocation::Workspace, &mut capsules)?; } @@ -296,4 +336,30 @@ mod tests { "corrupt meta.json should be treated as missing" ); } + + #[test] + fn scan_uses_only_injected_workspace_layout() { + let home_dir = tempfile::tempdir().unwrap(); + let home = AstridHome::from_path(home_dir.path()); + let workspace = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(workspace.path().join(".astrid/capsules/default-capsule")).unwrap(); + std::fs::create_dir_all( + workspace + .path() + .join(".alternate-runtime/capsules/alternate-capsule"), + ) + .unwrap(); + + let layout = WorkspaceLayout::new(".alternate-runtime").unwrap(); + let capsules = scan_installed_capsules_in_home_for_in_workspace( + &home, + &crate::paths::install_principal(), + Some(workspace.path()), + &layout, + ) + .unwrap(); + + assert_eq!(capsules.len(), 1); + assert_eq!(capsules[0].name, "alternate-capsule"); + } } diff --git a/crates/astrid-capsule-install/src/paths.rs b/crates/astrid-capsule-install/src/paths.rs index dbae97a63..eaf922492 100644 --- a/crates/astrid-capsule-install/src/paths.rs +++ b/crates/astrid-capsule-install/src/paths.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use anyhow::Context; use astrid_core::PrincipalId; -use astrid_core::dirs::AstridHome; +use astrid_core::dirs::{AstridHome, WorkspaceLayout}; /// The principal a non-workspace install targets. /// @@ -23,9 +23,19 @@ pub fn install_principal() -> astrid_core::PrincipalId { /// /// User installs (`workspace = false`) land in the principal's home /// under `capsules//`, for the [`install_principal`]. Workspace -/// installs go to `/.astrid/capsules//`. +/// installs go under the selected project state directory. pub fn resolve_target_dir(home: &AstridHome, id: &str, workspace: bool) -> anyhow::Result { - resolve_target_dir_for(home, &install_principal(), id, workspace) + resolve_target_dir_with_layout(home, id, workspace, &WorkspaceLayout::default()) +} + +/// Resolve a capsule target using an explicit workspace layout. +pub fn resolve_target_dir_with_layout( + home: &AstridHome, + id: &str, + workspace: bool, + workspace_layout: &WorkspaceLayout, +) -> anyhow::Result { + resolve_target_dir_for_with_layout(home, &install_principal(), id, workspace, workspace_layout) } /// Resolve the directory a capsule should be installed into for `principal`. @@ -34,16 +44,74 @@ pub fn resolve_target_dir_for( principal: &PrincipalId, id: &str, workspace: bool, +) -> anyhow::Result { + resolve_target_dir_for_with_layout(home, principal, id, workspace, &WorkspaceLayout::default()) +} + +/// Resolve a capsule target for `principal` using an explicit workspace layout. +pub fn resolve_target_dir_for_with_layout( + home: &AstridHome, + principal: &PrincipalId, + id: &str, + workspace: bool, + workspace_layout: &WorkspaceLayout, +) -> anyhow::Result { + let workspace_root = if workspace { + Some(std::env::current_dir().context("could not determine current directory")?) + } else { + None + }; + resolve_target_dir_for_in_workspace( + home, + principal, + id, + workspace, + workspace_root.as_deref(), + workspace_layout, + ) +} + +/// Resolve a capsule target using an explicit workspace root and layout. +pub fn resolve_target_dir_for_in_workspace( + home: &AstridHome, + principal: &PrincipalId, + id: &str, + workspace: bool, + workspace_root: Option<&Path>, + workspace_layout: &WorkspaceLayout, ) -> anyhow::Result { if workspace { - let root = std::env::current_dir().context("could not determine current directory")?; - Ok(root.join(".astrid").join("capsules").join(id)) + let root = workspace_root.context("workspace install requires a workspace root")?; + Ok(workspace_layout.capsules_dir(root).join(id)) } else { let ph = home.principal_home(principal); Ok(ph.capsules_dir().join(id)) } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workspace_target_uses_injected_layout() { + let layout = WorkspaceLayout::new(".alternate-runtime").unwrap(); + let root = Path::new("/workspace"); + assert_eq!( + resolve_target_dir_for_in_workspace( + &AstridHome::from_path("/home/runtime"), + &install_principal(), + "example", + true, + Some(root), + &layout, + ) + .unwrap(), + PathBuf::from("/workspace/.alternate-runtime/capsules/example") + ); + } +} + /// Resolve the path to a capsule's env config file. /// /// Returns `home/{principal}/.config/env/{capsule}.env.json`. diff --git a/crates/astrid-capsule-install/src/wit.rs b/crates/astrid-capsule-install/src/wit.rs index 13d808f0d..fb810c8aa 100644 --- a/crates/astrid-capsule-install/src/wit.rs +++ b/crates/astrid-capsule-install/src/wit.rs @@ -195,9 +195,8 @@ fn persist_wit_blob(wit_store: &Path, hash: &str, content: &[u8]) { /// `home.principal_home(principal)/wit`. The caller passes /// [`crate::paths::install_principal`] — the same id used for the home-scoped /// install paths — so introspection tools resolve the mirror under the home -/// they read from. (For a workspace install the capsule itself lives under -/// `/.astrid`, so `principal` names the mirror home, not necessarily the -/// capsule's own directory.) +/// they read from. For a workspace install, `principal` names the mirror home, +/// not necessarily the capsule's project-state directory. /// /// `wit_files` is the name→hash map returned by [`content_address_wit`]: /// keys are paths relative to the source `wit/` directory (e.g. diff --git a/crates/astrid-capsule/src/discovery.rs b/crates/astrid-capsule/src/discovery.rs index e6814ce6e..a0026d3ed 100644 --- a/crates/astrid-capsule/src/discovery.rs +++ b/crates/astrid-capsule/src/discovery.rs @@ -6,6 +6,7 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; +use astrid_core::dirs::WorkspaceLayout; use tracing::{debug, info, warn}; use crate::error::{CapsuleError, CapsuleResult}; @@ -91,7 +92,7 @@ fn validate_cli_verb_name(name: &str) -> Result<(), String> { /// /// Scans directories in priority order: /// 1. `extra_paths` (system and principal capsule dirs, passed by kernel) -/// 2. `.astrid/capsules/` (workspace-level, relative to CWD) +/// 2. Capsules under the selected project state directory /// /// **Deduplication:** When the same `package.name` appears in multiple /// sources, the first occurrence wins (highest priority). Lower-priority @@ -103,6 +104,24 @@ fn validate_cli_verb_name(name: &str) -> Result<(), String> { /// Returns `(manifest, capsule_dir)` pairs where `capsule_dir` is the /// directory containing the manifest. pub fn discover_manifests(extra_paths: Option<&[PathBuf]>) -> Vec<(CapsuleManifest, PathBuf)> { + discover_manifests_with_layout(extra_paths, &WorkspaceLayout::default()) +} + +/// Discover capsule manifests using an explicit workspace layout. +pub fn discover_manifests_with_layout( + extra_paths: Option<&[PathBuf]>, + workspace_layout: &WorkspaceLayout, +) -> Vec<(CapsuleManifest, PathBuf)> { + let workspace_root = std::env::current_dir().ok(); + discover_manifests_in_workspace(extra_paths, workspace_root.as_deref(), workspace_layout) +} + +/// Discover capsule manifests with an explicit workspace root and layout. +pub fn discover_manifests_in_workspace( + extra_paths: Option<&[PathBuf]>, + workspace_root: Option<&Path>, + workspace_layout: &WorkspaceLayout, +) -> Vec<(CapsuleManifest, PathBuf)> { let mut manifests = Vec::new(); let mut seen_names: HashSet = HashSet::new(); @@ -140,7 +159,9 @@ pub fn discover_manifests(extra_paths: Option<&[PathBuf]>) -> Vec<(CapsuleManife } // 2. Workspace-level capsules (lowest priority). - load_dedup(&PathBuf::from(".astrid/capsules"), "workspace"); + if let Some(workspace_root) = workspace_root { + load_dedup(&workspace_layout.capsules_dir(workspace_root), "workspace"); + } info!(count = manifests.len(), "Discovered capsule manifests"); manifests @@ -367,6 +388,33 @@ name = "test-capsule" version = "0.1.0" "#; + #[test] + fn discovery_uses_only_the_injected_workspace_layout() { + let workspace = tempfile::tempdir().unwrap(); + let default_capsule = workspace.path().join(".astrid/capsules/default-capsule"); + let alternate_capsule = workspace + .path() + .join(".alternate-runtime/capsules/alternate-capsule"); + std::fs::create_dir_all(&default_capsule).unwrap(); + std::fs::create_dir_all(&alternate_capsule).unwrap(); + std::fs::write( + default_capsule.join("Capsule.toml"), + "[package]\nname = \"default-capsule\"\nversion = \"1.0.0\"\n", + ) + .unwrap(); + std::fs::write( + alternate_capsule.join("Capsule.toml"), + "[package]\nname = \"alternate-capsule\"\nversion = \"1.0.0\"\n", + ) + .unwrap(); + + let layout = WorkspaceLayout::new(".alternate-runtime").unwrap(); + let found = discover_manifests_in_workspace(None, Some(workspace.path()), &layout); + + assert_eq!(found.len(), 1); + assert_eq!(found[0].0.package.name, "alternate-capsule"); + } + #[test] fn load_manifest_accepts_valid_ipc_publish() { let toml = format!( diff --git a/crates/astrid-cli/src/bootstrap.rs b/crates/astrid-cli/src/bootstrap.rs index a85ac59a4..47121b9ca 100644 --- a/crates/astrid-cli/src/bootstrap.rs +++ b/crates/astrid-cli/src/bootstrap.rs @@ -26,9 +26,12 @@ pub(crate) async fn ensure_global_config() { /// Configure tracing/logging for this CLI invocation. pub(crate) fn init_logging(cli: &Cli) { let workspace_root = std::env::current_dir().ok(); - let unified_cfg = astrid_config::Config::load(workspace_root.as_deref()) - .ok() - .map(|r| r.config); + let unified_cfg = astrid_config::Config::load_with_layout( + workspace_root.as_deref(), + crate::workspace_layout::current(), + ) + .ok() + .map(|r| r.config); let needs_file_log = matches!(cli.command, Some(crate::cli::Commands::Chat { .. }) | None); @@ -136,7 +139,7 @@ pub(crate) fn run_build_companion( /// Returns an error if the kernel fails to boot or the socket fails to connect. pub(crate) async fn run_or_connect( session: Option, - _workspace: Option, + workspace: Option, format: OutputFormat, ) -> Result<()> { use astrid_core::SessionId; @@ -190,10 +193,11 @@ pub(crate) async fn run_or_connect( } let mut client = - match socket_client::SocketClient::connect(session_id.clone(), crate::principal::current()) + match socket_client::connect_for_workspace(session_id.clone(), crate::principal::current()) .await { Ok(c) => { + commands::daemon::ensure_daemon_workspace_matches(workspace.as_deref()).await?; drop(daemon_child); c }, @@ -216,9 +220,12 @@ pub(crate) async fn run_or_connect( }; let workspace_root = std::env::current_dir().ok(); - let model_name = astrid_config::Config::load(workspace_root.as_deref()) - .ok() - .map_or_else(|| "unknown".to_string(), |r| r.config.model.model); + let model_name = astrid_config::Config::load_with_layout( + workspace_root.as_deref(), + crate::workspace_layout::current(), + ) + .ok() + .map_or_else(|| "unknown".to_string(), |r| r.config.model.model); crate::commands::chat::run_chat(&mut client, &session_id, &model_name, format).await } diff --git a/crates/astrid-cli/src/cli.rs b/crates/astrid-cli/src/cli.rs index 25d4994a1..6646b4a5c 100644 --- a/crates/astrid-cli/src/cli.rs +++ b/crates/astrid-cli/src/cli.rs @@ -47,6 +47,15 @@ pub(crate) struct Cli { )] pub principal: Option, + /// Per-project runtime state directory name. + #[arg( + long, + global = true, + env = "ASTRID_WORKSPACE_STATE_DIR", + default_value = astrid_core::dirs::DEFAULT_WORKSPACE_STATE_DIR + )] + pub workspace_state_dir: astrid_core::dirs::WorkspaceLayout, + /// Non-interactive prompt. Sends the prompt, prints the response, and exits. /// Forces headless mode (no TUI). Stdin is appended to the prompt if piped. #[arg(short, long)] @@ -572,6 +581,34 @@ mod tests { use clap::{CommandFactory, Parser, Subcommand}; use std::collections::BTreeSet; + #[test] + fn workspace_layout_defaults_and_accepts_an_injected_name() { + let default = Cli::try_parse_from(["astrid", "status"]).unwrap(); + assert_eq!(default.workspace_state_dir.state_dir_name(), ".astrid"); + + let alternate = Cli::try_parse_from([ + "astrid", + "--workspace-state-dir", + ".alternate-runtime", + "status", + ]) + .unwrap(); + assert_eq!( + alternate.workspace_state_dir.state_dir_name(), + ".alternate-runtime" + ); + } + + #[test] + fn workspace_layout_rejects_unsafe_cli_input() { + for value in ["", ".", "..", "/tmp/state", "nested/state", "CON"] { + assert!( + Cli::try_parse_from(["astrid", "--workspace-state-dir", value, "status"]).is_err(), + "{value:?} must be rejected" + ); + } + } + /// Every built-in `astrid capsule` subcommand name must appear in /// [`astrid_core::kernel_api::RESERVED_CAPSULE_VERBS`]. The reserved /// list is what manifest parsing uses to reject a `kind = "cli"` diff --git a/crates/astrid-cli/src/commands/capsule/deps.rs b/crates/astrid-cli/src/commands/capsule/deps.rs index ff86b2204..e0cadc02f 100644 --- a/crates/astrid-cli/src/commands/capsule/deps.rs +++ b/crates/astrid-cli/src/commands/capsule/deps.rs @@ -2,7 +2,7 @@ use colored::Colorize; -use super::meta::{InstalledCapsule, scan_installed_capsules}; +use super::meta::{InstalledCapsule, scan_installed_capsules_with_layout}; use crate::theme::Theme; // --------------------------------------------------------------------------- @@ -140,7 +140,7 @@ fn build_dep_graph(capsules: &[InstalledCapsule]) -> (Vec>, Vec< /// Show the capsule dependency tree (imports/exports graph). pub(crate) fn show_tree() -> anyhow::Result<()> { - let capsules = scan_installed_capsules()?; + let capsules = scan_installed_capsules_with_layout(crate::workspace_layout::current())?; if capsules.is_empty() { println!("{}", Theme::info("No capsules installed.")); diff --git a/crates/astrid-cli/src/commands/capsule/install.rs b/crates/astrid-cli/src/commands/capsule/install.rs index 81fdb418b..49a666e5d 100644 --- a/crates/astrid-cli/src/commands/capsule/install.rs +++ b/crates/astrid-cli/src/commands/capsule/install.rs @@ -32,7 +32,7 @@ use astrid_capsule::capsule::CapsuleId; use astrid_capsule_install::github_source::{ capsule_assets, extract_github_org_repo, parse_github_source, pick_capsule, }; -use astrid_capsule_install::{InstallOptions, InstallOutput}; +use astrid_capsule_install::{InstallOptions, InstallOutput, resolve_target_dir_for_with_layout}; use astrid_core::dirs::AstridHome; use astrid_events::EventBus; @@ -695,17 +695,22 @@ fn install_from_local_path_for_principal( }; match expected { Some(expected) => { - astrid_capsule_install::install_from_local_path_checked_for_principal( + astrid_capsule_install::install_from_local_path_checked_for_principal_with_layout( source_dir, home, opts, principal, expected.id, expected.version, + crate::workspace_layout::current(), ) }, - None => astrid_capsule_install::install_from_local_path_for_principal( - source_dir, home, opts, principal, + None => astrid_capsule_install::install_from_local_path_for_principal_with_layout( + source_dir, + home, + opts, + principal, + crate::workspace_layout::current(), ), } })?; @@ -744,9 +749,15 @@ pub(crate) fn install_offline_capsule( }), )?; // Post-stamp provenance into the freshly-written meta.json. The - // unpack above installs under the explicit target principal, so - // read metadata back from that same home. - let target_dir = resolve_target_dir_for(home, principal, expected.as_str(), false)?; + // unpack above installs under the explicit target principal and + // selected workspace layout, so read metadata through the same path. + let target_dir = resolve_target_dir_for_with_layout( + home, + principal, + expected.as_str(), + false, + crate::workspace_layout::current(), + )?; if let Some(mut meta) = super::meta::read_meta(&target_dir) { meta.resolved_ref = provenance.resolved_ref.map(String::from); meta.signer = provenance.signer.map(String::from); @@ -781,16 +792,23 @@ fn unpack_via_lib( ..opts }; match expected { - Some(expected) => astrid_capsule_install::unpack_and_install_checked_for_principal( + Some(expected) => { + astrid_capsule_install::unpack_and_install_checked_for_principal_with_layout( + archive, + home, + opts, + principal, + expected.id, + expected.version, + crate::workspace_layout::current(), + ) + }, + None => astrid_capsule_install::unpack_and_install_for_principal_with_layout( archive, home, opts, principal, - expected.id, - expected.version, - ), - None => astrid_capsule_install::unpack_and_install_for_principal( - archive, home, opts, principal, + crate::workspace_layout::current(), ), } })?; diff --git a/crates/astrid-cli/src/commands/capsule/install_update.rs b/crates/astrid-cli/src/commands/capsule/install_update.rs index e4529fcfe..33e29a29f 100644 --- a/crates/astrid-cli/src/commands/capsule/install_update.rs +++ b/crates/astrid-cli/src/commands/capsule/install_update.rs @@ -12,7 +12,9 @@ use anyhow::{Context, bail}; use astrid_capsule_install::github_source::{parse_github_source, strip_version_prefix}; -use astrid_capsule_install::scan_installed_capsules_in_home_for; +use astrid_capsule_install::{ + CapsuleLocation, InstalledCapsule, scan_installed_capsules_in_home_for_with_layout, +}; use astrid_core::dirs::AstridHome; use super::install::install_capsule; @@ -115,8 +117,13 @@ pub(crate) async fn update_capsule(target: Option<&str>, workspace: bool) -> any let principal = crate::principal::current(); if let Some(name) = target { - let target_dir = - astrid_capsule_install::resolve_target_dir_for(&home, &principal, name, workspace)?; + let target_dir = astrid_capsule_install::resolve_target_dir_for_with_layout( + &home, + &principal, + name, + workspace, + crate::workspace_layout::current(), + )?; if !target_dir.exists() { bail!("Capsule '{name}' is not installed."); } @@ -152,11 +159,17 @@ async fn update_all_capsules( principal: &astrid_core::PrincipalId, workspace: bool, ) -> anyhow::Result<()> { - let capsules: Vec<(String, Option)> = - scan_installed_capsules_in_home_for(home, principal)? - .into_iter() - .map(|capsule| (capsule.name, capsule.meta)) - .collect(); + let capsules: Vec<(String, Option)> = filter_update_scope( + scan_installed_capsules_in_home_for_with_layout( + home, + principal, + crate::workspace_layout::current(), + )?, + workspace, + ) + .into_iter() + .map(|capsule| (capsule.name, capsule.meta)) + .collect(); if capsules.is_empty() { eprintln!("No capsules installed."); @@ -236,6 +249,18 @@ async fn update_all_capsules( Ok(()) } +fn filter_update_scope(capsules: Vec, workspace: bool) -> Vec { + let selected = if workspace { + CapsuleLocation::Workspace + } else { + CapsuleLocation::User + }; + capsules + .into_iter() + .filter(|capsule| capsule.location == selected) + .collect() +} + /// Regenerate the Distro.lock from currently installed capsules. /// /// Scans all installed capsules, reads their `meta.json`, and writes @@ -256,7 +281,11 @@ fn regenerate_distro_lock( return Ok(()); }; - let all = scan_installed_capsules_in_home_for(home, principal)?; + let all = scan_installed_capsules_in_home_for_with_layout( + home, + principal, + crate::workspace_layout::current(), + )?; let capsules: Vec = all .iter() .map(|c| { @@ -312,6 +341,37 @@ fn regenerate_distro_lock( mod tests { use super::*; + fn installed(name: &str, location: CapsuleLocation) -> InstalledCapsule { + InstalledCapsule { + name: name.to_string(), + meta: None, + location, + } + } + + #[test] + fn update_scope_does_not_move_capsules_between_locations() { + let user = filter_update_scope( + vec![ + installed("user-only", CapsuleLocation::User), + installed("workspace-only", CapsuleLocation::Workspace), + ], + false, + ); + assert_eq!(user.len(), 1); + assert_eq!(user[0].name, "user-only"); + + let workspace = filter_update_scope( + vec![ + installed("user-only", CapsuleLocation::User), + installed("workspace-only", CapsuleLocation::Workspace), + ], + true, + ); + assert_eq!(workspace.len(), 1); + assert_eq!(workspace[0].name, "workspace-only"); + } + #[tokio::test] async fn test_check_remote_version_invalid_semver() { let client = reqwest::Client::new(); diff --git a/crates/astrid-cli/src/commands/capsule/list.rs b/crates/astrid-cli/src/commands/capsule/list.rs index 8e9f29995..8c9d020ad 100644 --- a/crates/astrid-cli/src/commands/capsule/list.rs +++ b/crates/astrid-cli/src/commands/capsule/list.rs @@ -6,7 +6,7 @@ use astrid_capsule_install::mismatching_contracts; use astrid_core::dirs::AstridHome; use colored::Colorize; -use super::meta::scan_installed_capsules_in_home; +use super::meta::scan_installed_capsules_in_home_with_layout; use crate::theme::Theme; /// List all installed capsules with their provides/requires metadata. @@ -16,7 +16,8 @@ use crate::theme::Theme; /// list and install source. pub(crate) fn list_capsules(verbose: bool) -> anyhow::Result<()> { let home = AstridHome::resolve()?; - let capsules = scan_installed_capsules_in_home(&home)?; + let capsules = + scan_installed_capsules_in_home_with_layout(&home, crate::workspace_layout::current())?; if capsules.is_empty() { println!("{}", Theme::info("No capsules installed.")); diff --git a/crates/astrid-cli/src/commands/capsule/live_load.rs b/crates/astrid-cli/src/commands/capsule/live_load.rs index fecd5a1b1..3cad10548 100644 --- a/crates/astrid-cli/src/commands/capsule/live_load.rs +++ b/crates/astrid-cli/src/commands/capsule/live_load.rs @@ -33,7 +33,6 @@ pub(crate) async fn nudge_daemon_reload(capsule_ids: &[String]) { if !crate::socket_client::proxy_socket_path().exists() { return; } - // One fresh session UUID, used for BOTH the connection's SessionId and each // message source_id, so these requests are attributed to a real client // session — never the reserved nil UUID, which is SYSTEM_SESSION_UUID. @@ -46,6 +45,11 @@ pub(crate) async fn nudge_daemon_reload(capsule_ids: &[String]) { // install standalone rather than failing it. return; }; + // Validate after connect so a concurrent daemon restart cannot retarget us. + if let Err(error) = crate::commands::daemon::ensure_daemon_workspace_matches(None).await { + eprintln!("Note: installed capsules to disk, but skipped live reload because {error}."); + return; + } for id in capsule_ids { let Ok(val) = serde_json::to_value(KernelRequest::ReloadCapsule { id: id.clone() }) else { @@ -116,7 +120,6 @@ pub(crate) async fn try_daemon_unload(capsule_id: &str) -> anyhow::Result anyhow::Result anyhow::Result<()> { - let target_dir = - astrid_capsule_install::resolve_target_dir_for(home, principal, name, workspace)?; + let target_dir = astrid_capsule_install::resolve_target_dir_for_with_layout( + home, + principal, + name, + workspace, + crate::workspace_layout::current(), + )?; // Content-addressed artifacts in bin/ and wit/ are NEVER deleted. // They are the audit trail — the BLAKE3 hash in audit entries must always @@ -116,8 +121,13 @@ fn validate_capsule_removal_from_home_for( workspace: bool, force: bool, ) -> anyhow::Result<()> { - let target_dir = - astrid_capsule_install::resolve_target_dir_for(home, principal, name, workspace)?; + let target_dir = astrid_capsule_install::resolve_target_dir_for_with_layout( + home, + principal, + name, + workspace, + crate::workspace_layout::current(), + )?; if !target_dir.exists() { bail!("Capsule '{name}' is not installed."); @@ -126,7 +136,11 @@ fn validate_capsule_removal_from_home_for( let target_meta = super::meta::read_meta(&target_dir); // Scan once, reuse for both dependency check and binary cleanup - let all_capsules = super::meta::scan_installed_capsules_in_home_for(home, principal)?; + let all_capsules = super::meta::scan_installed_capsules_in_home_for_with_layout( + home, + principal, + crate::workspace_layout::current(), + )?; // Dependency safety check (skip with --force) if !force && let Some(block) = check_removal_safety(name, target_meta.as_ref(), &all_capsules) { diff --git a/crates/astrid-cli/src/commands/capsule_verb.rs b/crates/astrid-cli/src/commands/capsule_verb.rs index 03051a4bd..a4f7d07ee 100644 --- a/crates/astrid-cli/src/commands/capsule_verb.rs +++ b/crates/astrid-cli/src/commands/capsule_verb.rs @@ -171,7 +171,7 @@ async fn resolve_commands() -> Result> { // (admin) principal — letting a non-admin enumerate capsule verbs under // admin context, an RBAC bypass. let caller = crate::principal::current(); - let mut client = SocketClient::connect(session, caller.clone()) + let mut client = crate::socket_client::connect_for_workspace(session, caller.clone()) .await .map_err(|e| anyhow::anyhow!("Failed to connect to daemon: {e}"))?; @@ -209,16 +209,17 @@ async fn execute(provider: &str, verb: &str, args: &[String]) -> Result c, - Err(e) => { - eprintln!( - "{}", - Theme::error(&format!("Failed to connect to daemon: {e}")) - ); - return Ok(ExitCode::from(1)); - }, - }; + let mut client = + match crate::socket_client::connect_for_workspace(session, caller.clone()).await { + Ok(c) => c, + Err(e) => { + eprintln!( + "{}", + Theme::error(&format!("Failed to connect to daemon: {e}")) + ); + return Ok(ExitCode::from(1)); + }, + }; let req_id = Uuid::new_v4().simple().to_string(); let body = serde_json::json!({ diff --git a/crates/astrid-cli/src/commands/config.rs b/crates/astrid-cli/src/commands/config.rs index dbf6e7d16..9e78b8747 100644 --- a/crates/astrid-cli/src/commands/config.rs +++ b/crates/astrid-cli/src/commands/config.rs @@ -6,7 +6,10 @@ use astrid_config::{Config, ResolvedConfig, ShowFormat}; /// Show the resolved configuration with source annotations. pub(crate) fn show_config(format: &str, section: Option<&str>) -> Result<()> { let workspace_root = std::env::current_dir().ok(); - let resolved = Config::load(workspace_root.as_deref())?; + let resolved = Config::load_with_layout( + workspace_root.as_deref(), + crate::workspace_layout::current(), + )?; let show_format = match format { "json" => ShowFormat::Json, @@ -26,7 +29,10 @@ pub(crate) fn show_config(format: &str, section: Option<&str>) -> Result<()> { pub(crate) fn validate_config() -> Result<()> { let workspace_root = std::env::current_dir().ok(); - match Config::load(workspace_root.as_deref()) { + match Config::load_with_layout( + workspace_root.as_deref(), + crate::workspace_layout::current(), + ) { Ok(resolved) => { println!("Configuration is valid."); if !resolved.loaded_files.is_empty() { @@ -81,10 +87,11 @@ pub(crate) fn show_paths() -> Result<()> { .map(|p| p.to_string_lossy().to_string()); let astrid_home = std::env::var("ASTRID_HOME").ok(); - let paths = ResolvedConfig::config_paths_with_astrid_home( + let paths = ResolvedConfig::config_paths_with_layout( home.as_deref(), astrid_home.as_deref(), workspace.as_deref(), + crate::workspace_layout::current(), ); println!("Configuration files checked (in precedence order):\n"); diff --git a/crates/astrid-cli/src/commands/daemon.rs b/crates/astrid-cli/src/commands/daemon.rs index ed3e34096..00896adb5 100644 --- a/crates/astrid-cli/src/commands/daemon.rs +++ b/crates/astrid-cli/src/commands/daemon.rs @@ -80,7 +80,10 @@ async fn spawn_daemon_inner( let daemon_bin = find_companion_binary("astrid-daemon")?; let mut cmd = std::process::Command::new(daemon_bin); - cmd.arg("--ephemeral"); + cmd.arg("--ephemeral").env( + "ASTRID_WORKSPACE_STATE_DIR", + crate::workspace_layout::current().state_dir_name(), + ); if let Some(ws_path) = ws.to_str() { cmd.arg("--workspace").arg(ws_path); @@ -155,6 +158,7 @@ async fn ensure_daemon_inner(label: &str, announce: bool) -> Result<()> { let needs_boot = if socket_path.exists() { if tokio::net::UnixStream::connect(&socket_path).await.is_ok() { + ensure_daemon_workspace_matches(None).await?; if announce { eprintln!("[{label}] Connected to existing daemon"); } @@ -169,6 +173,50 @@ async fn ensure_daemon_inner(label: &str, announce: bool) -> Result<()> { }; if needs_boot { spawn_daemon_inner(&ready_path, announce).await?; + ensure_daemon_workspace_matches(None).await?; + } + Ok(()) +} + +pub(crate) async fn ensure_daemon_workspace_matches(workspace_root: Option<&Path>) -> Result<()> { + let root = workspace_root.map_or_else( + || std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + Path::to_path_buf, + ); + let expected = astrid_core::dirs::workspace_selection_fingerprint( + &root, + crate::workspace_layout::current(), + ); + let ready_path = socket_client::readiness_path(); + + for _ in 0..DAEMON_READY_ATTEMPTS { + match std::fs::read_to_string(&ready_path) { + Ok(metadata) => return validate_daemon_workspace_metadata(&metadata, &expected), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + tokio::time::sleep(DAEMON_READY_POLL).await; + }, + Err(error) => { + return Err(error).context("failed to read daemon workspace metadata"); + }, + } + } + + anyhow::bail!( + "daemon workspace metadata was not available within {} seconds; run `astrid restart`", + DAEMON_READY_TIMEOUT_SECS + ) +} + +fn validate_daemon_workspace_metadata(metadata: &str, expected: &str) -> Result<()> { + let Some(actual) = metadata.trim().strip_prefix("v1:") else { + anyhow::bail!( + "running daemon does not expose workspace selection metadata; run `astrid restart`" + ); + }; + if actual != expected { + anyhow::bail!( + "running daemon belongs to another project or workspace layout; run `astrid restart` from this project" + ); } Ok(()) } @@ -185,6 +233,10 @@ pub(crate) async fn spawn_persistent_daemon() -> Result<()> { let mut cmd = std::process::Command::new(daemon_bin); // No --ephemeral flag = persistent mode + cmd.env( + "ASTRID_WORKSPACE_STATE_DIR", + crate::workspace_layout::current().state_dir_name(), + ); if let Some(ws_path) = ws.to_str() { cmd.arg("--workspace").arg(ws_path); @@ -312,13 +364,19 @@ pub(crate) async fn handle_start() -> Result<()> { let ready_path = socket_client::readiness_path(); let pid_path = socket_client::pid_path(); - let socket_reachable = - socket_path.exists() && tokio::net::UnixStream::connect(&socket_path).await.is_ok(); + let socket_probe = if socket_path.exists() { + tokio::net::UnixStream::connect(&socket_path).await.ok() + } else { + None + }; + let socket_reachable = socket_probe.is_some(); let recorded_pid_alive = daemon_control::read_pid_file(&pid_path) .is_some_and(|(pid, _)| daemon_control::is_process_alive(pid)); match decide_start_action(socket_reachable, recorded_pid_alive) { StartAction::AlreadyRunning => { + ensure_daemon_workspace_matches(None).await?; + drop(socket_probe); println!( "{}", theme::Theme::warning("Astrid daemon is already running.") @@ -613,6 +671,17 @@ mod tests { ); } + #[test] + fn daemon_workspace_metadata_rejects_unknown_or_different_selection() { + let expected = "a".repeat(64); + assert!(validate_daemon_workspace_metadata("", &expected).is_err()); + assert!( + validate_daemon_workspace_metadata(&format!("v1:{}", "b".repeat(64)), &expected) + .is_err() + ); + validate_daemon_workspace_metadata(&format!("v1:{expected}\n"), &expected).unwrap(); + } + /// REGRESSION (#1120): `astrid stop` must remove the socket/PID files ONLY /// when the daemon is confirmed gone. Before the fix, stop reported success /// and deleted the PID file on the shutdown ACK alone; a daemon that then diff --git a/crates/astrid-cli/src/commands/headless.rs b/crates/astrid-cli/src/commands/headless.rs index 04bbc4a34..a75b8630f 100644 --- a/crates/astrid-cli/src/commands/headless.rs +++ b/crates/astrid-cli/src/commands/headless.rs @@ -51,7 +51,7 @@ pub(crate) async fn run_snapshot_tui( }; let mut client = - socket_client::SocketClient::connect(session_id.clone(), crate::principal::current()) + socket_client::connect_for_workspace(session_id.clone(), crate::principal::current()) .await .context("Failed to connect to daemon")?; @@ -114,7 +114,7 @@ pub(crate) async fn run_headless( SessionId::from_uuid(id) }; let mut client = - socket_client::SocketClient::connect(session_id.clone(), crate::principal::current()) + socket_client::connect_for_workspace(session_id.clone(), crate::principal::current()) .await .context("Failed to connect to daemon")?; diff --git a/crates/astrid-cli/src/commands/init.rs b/crates/astrid-cli/src/commands/init.rs index b9c2ecf24..621215e7a 100644 --- a/crates/astrid-cli/src/commands/init.rs +++ b/crates/astrid-cli/src/commands/init.rs @@ -263,7 +263,10 @@ fn run_init_from_shuttle(source: &str, opts: &InitOpts) -> anyhow::Result<()> { /// Initialize the current directory as an Astrid workspace (if not already). fn init_workspace() -> anyhow::Result<()> { let cwd = std::env::current_dir()?; - let ws = astrid_core::dirs::WorkspaceDir::from_path(&cwd); + let ws = astrid_core::dirs::WorkspaceDir::from_path_with_layout( + &cwd, + crate::workspace_layout::current().clone(), + ); if !ws.dot_astrid().exists() { ws.ensure()?; diff --git a/crates/astrid-cli/src/commands/mcp/mod.rs b/crates/astrid-cli/src/commands/mcp/mod.rs index 902a0e9a7..e91a0e814 100644 --- a/crates/astrid-cli/src/commands/mcp/mod.rs +++ b/crates/astrid-cli/src/commands/mcp/mod.rs @@ -53,8 +53,6 @@ use tokio::sync::Mutex; use tracing::info; use uuid::Uuid; -use crate::socket_client::SocketClient; - use server::AstridMcpServer; /// Refuse to serve the MCP bridge silently as the no-capability `anonymous` @@ -122,7 +120,7 @@ pub(crate) async fn serve(principal: Option<&str>) -> Result { // not a chat session; the kernel attributes work via the per-message // `principal`, not the session. let session = astrid_core::SessionId::from_uuid(Uuid::new_v4()); - let client = SocketClient::connect(session, caller.clone()) + let client = crate::socket_client::connect_for_workspace(session, caller.clone()) .await .context("Failed to connect to the Astrid daemon socket")?; diff --git a/crates/astrid-cli/src/commands/mcp/session_guard.rs b/crates/astrid-cli/src/commands/mcp/session_guard.rs index 37599f4ce..005bb3716 100644 --- a/crates/astrid-cli/src/commands/mcp/session_guard.rs +++ b/crates/astrid-cli/src/commands/mcp/session_guard.rs @@ -12,7 +12,6 @@ use tracing::{debug, warn}; use uuid::Uuid; use crate::commands::daemon; -use crate::socket_client::SocketClient; const INITIAL_RETRY_BACKOFF: Duration = Duration::from_secs(2); const MAX_RETRY_BACKOFF: Duration = Duration::from_secs(30); @@ -38,7 +37,7 @@ async fn hold_guard_uplink( daemon::ensure_daemon_quiet("mcp-session-guard").await?; let session = astrid_core::SessionId::from_uuid(Uuid::new_v4()); - let c = SocketClient::connect(session, principal.clone()) + let c = crate::socket_client::connect_for_workspace(session, principal.clone()) .await .context("failed to connect guard uplink to daemon")?; diff --git a/crates/astrid-cli/src/commands/mcp/watch.rs b/crates/astrid-cli/src/commands/mcp/watch.rs index 3423f5f61..fa7cd0a2d 100644 --- a/crates/astrid-cli/src/commands/mcp/watch.rs +++ b/crates/astrid-cli/src/commands/mcp/watch.rs @@ -84,7 +84,8 @@ pub(super) async fn run(peer: Peer, principal: String) { }, }; let session = astrid_core::SessionId::from_uuid(Uuid::new_v4()); - let mut watch_client = match SocketClient::connect(session, caller).await { + let mut watch_client = match crate::socket_client::connect_for_workspace(session, caller).await + { Ok(c) => c, Err(e) => { // Non-fatal: the server still serves tools; clients just won't diff --git a/crates/astrid-cli/src/commands/wit.rs b/crates/astrid-cli/src/commands/wit.rs index 50939d04d..5b671771d 100644 --- a/crates/astrid-cli/src/commands/wit.rs +++ b/crates/astrid-cli/src/commands/wit.rs @@ -50,7 +50,7 @@ pub(crate) fn gc(force: bool) -> anyhow::Result<()> { } // Build the mark set: every WIT hash referenced by any installed capsule. - let marks = collect_marks(&home)?; + let marks = collect_marks(&home, crate::workspace_layout::current())?; // Scan the store and identify orphans. let mut orphans = Vec::new(); @@ -142,7 +142,19 @@ pub(crate) fn gc(force: bool) -> anyhow::Result<()> { /// Collect the set of hashes referenced by every installed capsule's /// `meta.json` across all principals and the workspace. -fn collect_marks(home: &AstridHome) -> anyhow::Result> { +fn collect_marks( + home: &AstridHome, + workspace_layout: &astrid_core::dirs::WorkspaceLayout, +) -> anyhow::Result> { + let workspace_root = std::env::current_dir().ok(); + collect_marks_in_workspace(home, workspace_root.as_deref(), workspace_layout) +} + +fn collect_marks_in_workspace( + home: &AstridHome, + workspace_root: Option<&Path>, + workspace_layout: &astrid_core::dirs::WorkspaceLayout, +) -> anyhow::Result> { let mut marks = HashSet::new(); // Walk every principal home under ~/.astrid/home/ @@ -167,8 +179,8 @@ fn collect_marks(home: &AstridHome) -> anyhow::Result> { } // Workspace-level capsules (if running from a workspace) - if let Ok(cwd) = std::env::current_dir() { - let ws_caps = cwd.join(".astrid").join("capsules"); + if let Some(workspace_root) = workspace_root { + let ws_caps = workspace_layout.capsules_dir(workspace_root); if ws_caps.is_dir() { collect_from_capsules_dir(&ws_caps, &mut marks); } @@ -200,3 +212,38 @@ fn add_meta_marks(meta: &CapsuleMeta, marks: &mut HashSet) { marks.insert(hash.clone()); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wit_gc_marks_only_the_injected_workspace_root() { + let home_dir = tempfile::tempdir().unwrap(); + let home = AstridHome::from_path(home_dir.path()); + let workspace = tempfile::tempdir().unwrap(); + let default_capsule = workspace.path().join(".astrid/capsules/default"); + let alternate_capsule = workspace + .path() + .join(".alternate-runtime/capsules/alternate"); + std::fs::create_dir_all(&default_capsule).unwrap(); + std::fs::create_dir_all(&alternate_capsule).unwrap(); + + let mut default_meta = CapsuleMeta::default(); + default_meta + .wit_files + .insert("default.wit".into(), "default-hash".into()); + super::super::capsule::meta::write_meta(&default_capsule, &default_meta).unwrap(); + let mut alternate_meta = CapsuleMeta::default(); + alternate_meta + .wit_files + .insert("alternate.wit".into(), "alternate-hash".into()); + super::super::capsule::meta::write_meta(&alternate_capsule, &alternate_meta).unwrap(); + + let layout = astrid_core::dirs::WorkspaceLayout::new(".alternate-runtime").unwrap(); + let marks = collect_marks_in_workspace(&home, Some(workspace.path()), &layout).unwrap(); + + assert!(marks.contains("alternate-hash")); + assert!(!marks.contains("default-hash")); + } +} diff --git a/crates/astrid-cli/src/main.rs b/crates/astrid-cli/src/main.rs index 6055d52e3..81abb28e4 100644 --- a/crates/astrid-cli/src/main.rs +++ b/crates/astrid-cli/src/main.rs @@ -43,10 +43,15 @@ pub mod socket_client; mod theme; mod tui; mod value_formatter; +mod workspace_layout; #[tokio::main] async fn main() -> ExitCode { let parsed = cli::Cli::parse(); + if workspace_layout::initialize(parsed.workspace_state_dir.clone()).is_err() { + eprintln!("error: workspace layout was already initialized"); + return ExitCode::from(1); + } bootstrap::init_logging(&parsed); // Resolve and validate the process-wide principal ONCE, before any diff --git a/crates/astrid-cli/src/socket_client.rs b/crates/astrid-cli/src/socket_client.rs index 68c5e6f66..340727d80 100644 --- a/crates/astrid-cli/src/socket_client.rs +++ b/crates/astrid-cli/src/socket_client.rs @@ -11,6 +11,15 @@ pub(crate) use astrid_uplink::socket_client::{ use anyhow::Result; +pub(crate) async fn connect_for_workspace( + session: astrid_core::SessionId, + principal: astrid_core::PrincipalId, +) -> Result { + let client = SocketClient::connect(session, principal).await?; + crate::commands::daemon::ensure_daemon_workspace_matches(None).await?; + Ok(client) +} + /// Send a user prompt over an established uplink. /// /// The connection is already bound to the process principal (resolved diff --git a/crates/astrid-cli/src/workspace_layout.rs b/crates/astrid-cli/src/workspace_layout.rs new file mode 100644 index 000000000..5902fea91 --- /dev/null +++ b/crates/astrid-cli/src/workspace_layout.rs @@ -0,0 +1,13 @@ +use std::sync::OnceLock; + +use astrid_core::dirs::WorkspaceLayout; + +static WORKSPACE_LAYOUT: OnceLock = OnceLock::new(); + +pub(crate) fn initialize(layout: WorkspaceLayout) -> Result<(), WorkspaceLayout> { + WORKSPACE_LAYOUT.set(layout) +} + +pub(crate) fn current() -> &'static WorkspaceLayout { + WORKSPACE_LAYOUT.get_or_init(WorkspaceLayout::default) +} diff --git a/crates/astrid-config/Cargo.toml b/crates/astrid-config/Cargo.toml index fa789ddb3..70b3668d4 100644 --- a/crates/astrid-config/Cargo.toml +++ b/crates/astrid-config/Cargo.toml @@ -9,6 +9,7 @@ rust-version.workspace = true description = "Unified configuration system for Astrid" [dependencies] +astrid-core = { workspace = true } directories = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/astrid-config/src/defaults.toml b/crates/astrid-config/src/defaults.toml index d192297ad..32ba5c8c9 100644 --- a/crates/astrid-config/src/defaults.toml +++ b/crates/astrid-config/src/defaults.toml @@ -4,7 +4,7 @@ # It is embedded in the binary and serves as the fallback when no user config exists. # # Configuration precedence (highest to lowest): -# 1. Project config ({workspace}/.astrid/config.toml) — can only tighten security +# 1. Selected project config — can only tighten security # 2. User config (~/.astrid/config.toml) # 3. System config (/etc/astrid/config.toml) # 4. Environment variables (ASTRID_*, ANTHROPIC_*) — fallback only, not override diff --git a/crates/astrid-config/src/lib.rs b/crates/astrid-config/src/lib.rs index c4aebeaeb..9175621fc 100644 --- a/crates/astrid-config/src/lib.rs +++ b/crates/astrid-config/src/lib.rs @@ -19,18 +19,14 @@ //! //! From highest to lowest priority: //! -//! 1. **Workspace** (`{workspace}/.astrid/config.toml`) — can only *tighten* security +//! 1. **Workspace** (selected project state config) — can only *tighten* security //! 2. **User** (`~/.astrid/config.toml`) //! 3. **System** (`/etc/astrid/config.toml`) //! 4. **Environment variables** (`ASTRID_*`, `ANTHROPIC_*`) — fallback only //! 5. **Embedded defaults** (`defaults.toml` compiled into binary) //! -//! # Design -//! -//! This crate has **no dependencies on other internal astrid crates**. It only -//! depends on `serde`, `toml`, `thiserror`, `tracing`, and `directories`. -//! Conversion from config types to domain types happens at the integration -//! boundary (CLI startup, gateway init) via bridge modules. +//! Workspace file discovery accepts the runtime's validated +//! [`WorkspaceLayout`](astrid_core::dirs::WorkspaceLayout). #![deny(unsafe_code)] #![deny(missing_docs)] @@ -85,6 +81,33 @@ impl Config { loader::load(workspace_root, Some(astrid_home)) } + /// Load configuration with an explicit workspace layout. + /// + /// # Errors + /// + /// Returns a [`ConfigError`] if any config file is malformed or the final + /// configuration fails validation. + pub fn load_with_layout( + workspace_root: Option<&std::path::Path>, + workspace_layout: &astrid_core::dirs::WorkspaceLayout, + ) -> ConfigResult { + loader::load_with_layout(workspace_root, None, workspace_layout) + } + + /// Load configuration with explicit home and workspace layout inputs. + /// + /// # Errors + /// + /// Returns a [`ConfigError`] if any config file is malformed or the final + /// configuration fails validation. + pub fn load_with_home_and_layout( + workspace_root: Option<&std::path::Path>, + astrid_home: &std::path::Path, + workspace_layout: &astrid_core::dirs::WorkspaceLayout, + ) -> ConfigResult { + loader::load_with_layout(workspace_root, Some(astrid_home), workspace_layout) + } + /// Load configuration from a single file (no layering). /// /// # Errors diff --git a/crates/astrid-config/src/loader.rs b/crates/astrid-config/src/loader.rs index 63399e72a..04e0e7f32 100644 --- a/crates/astrid-config/src/loader.rs +++ b/crates/astrid-config/src/loader.rs @@ -4,7 +4,7 @@ //! 1. Parse `defaults.toml` → base //! 2. Merge `/etc/astrid/config.toml` (system) //! 3. Merge `$ASTRID_HOME/config.toml` or `~/.astrid/config.toml` (user) -//! 4. Merge `{workspace}/.astrid/config.toml` (workspace) + restriction enforcement +//! 4. Merge the selected workspace config + restriction enforcement //! 5. Apply env var fallbacks for unset fields //! 6. Deserialize merged tree → `Config` //! 7. Resolve `${VAR}` references @@ -13,6 +13,7 @@ use std::path::{Path, PathBuf}; +use astrid_core::dirs::WorkspaceLayout; use tracing::{debug, info}; use crate::env::{ @@ -43,6 +44,24 @@ const DEFAULTS_TOML: &str = include_str!("defaults.toml"); pub fn load( workspace_root: Option<&Path>, astrid_home_override: Option<&Path>, +) -> ConfigResult { + load_with_layout( + workspace_root, + astrid_home_override, + &WorkspaceLayout::default(), + ) +} + +/// Load the unified configuration using an explicit workspace layout. +/// +/// # Errors +/// +/// Returns a [`ConfigError`] if any config file is malformed, or if the +/// final merged configuration fails validation. +pub fn load_with_layout( + workspace_root: Option<&Path>, + astrid_home_override: Option<&Path>, + workspace_layout: &WorkspaceLayout, ) -> ConfigResult { let env_vars = collect_env_vars(); let home_dir = if let Some(h) = astrid_home_override { @@ -99,12 +118,12 @@ pub fn load( info!(path = %path.display(), "loaded user config"); } - // 4. Workspace config ({workspace}/.astrid/config.toml). + // 4. Selected workspace config. // Snapshot the merged config *before* the workspace layer as the baseline // for restriction enforcement. This ensures restrictions work even when // no user config file exists (the baseline includes defaults + system). if let Some(ws_root) = workspace_root { - let ws_path = ws_root.join(".astrid").join("config.toml"); + let ws_path = workspace_layout.config_path(ws_root); if let Some(mut overlay) = try_load_file(&ws_path)? { // Resolve ${VAR} references in workspace overlay with restricted // env vars (only ASTRID_* and ANTHROPIC_*). This prevents a @@ -337,6 +356,44 @@ mod tests { assert!(validate::validate(&config).is_ok()); } + #[test] + fn workspace_config_uses_only_injected_layout() { + let home = tempfile::tempdir().unwrap(); + let workspace = tempfile::tempdir().unwrap(); + let default_dir = workspace.path().join(".astrid"); + let alternate_dir = workspace.path().join(".alternate-runtime"); + std::fs::create_dir_all(&default_dir).unwrap(); + std::fs::create_dir_all(&alternate_dir).unwrap(); + std::fs::write( + default_dir.join("config.toml"), + "[model]\nprovider = \"default-root\"\n", + ) + .unwrap(); + std::fs::write( + alternate_dir.join("config.toml"), + "[model]\nprovider = \"alternate-root\"\n", + ) + .unwrap(); + + let layout = WorkspaceLayout::new(".alternate-runtime").unwrap(); + let resolved = + load_with_layout(Some(workspace.path()), Some(home.path()), &layout).unwrap(); + + assert_eq!(resolved.config.model.provider, "alternate-root"); + assert!( + resolved + .loaded_files + .iter() + .any(|path| path.ends_with(".alternate-runtime/config.toml")) + ); + assert!( + resolved + .loaded_files + .iter() + .all(|path| !path.ends_with(".astrid/config.toml")) + ); + } + #[test] fn test_load_file_nonexistent() { let result = load_file(Path::new("/nonexistent/config.toml")); diff --git a/crates/astrid-config/src/merge/types.rs b/crates/astrid-config/src/merge/types.rs index 6df38bf3b..5aad022be 100644 --- a/crates/astrid-config/src/merge/types.rs +++ b/crates/astrid-config/src/merge/types.rs @@ -9,7 +9,7 @@ pub enum ConfigLayer { System, /// User-level configuration (`~/.astrid/config.toml`). User, - /// Workspace-level configuration (`{workspace}/.astrid/config.toml`). + /// Workspace-level configuration under selected project state. Workspace, /// Environment variable fallback. Environment, @@ -21,7 +21,7 @@ impl std::fmt::Display for ConfigLayer { Self::Defaults => write!(f, "defaults"), Self::System => write!(f, "system (/etc/astrid/config.toml)"), Self::User => write!(f, "user (~/.astrid/config.toml)"), - Self::Workspace => write!(f, "workspace (.astrid/config.toml)"), + Self::Workspace => write!(f, "workspace config"), Self::Environment => write!(f, "environment variable"), } } diff --git a/crates/astrid-config/src/show.rs b/crates/astrid-config/src/show.rs index 4c3bc7a7f..425c81bb1 100644 --- a/crates/astrid-config/src/show.rs +++ b/crates/astrid-config/src/show.rs @@ -5,6 +5,8 @@ use std::fmt::{self, Write as _}; +use astrid_core::dirs::WorkspaceLayout; + use crate::merge::FieldSources; use crate::types::Config; @@ -129,6 +131,22 @@ impl ResolvedConfig { home_dir: Option<&str>, astrid_home: Option<&str>, workspace_root: Option<&str>, + ) -> Vec { + Self::config_paths_with_layout( + home_dir, + astrid_home, + workspace_root, + &WorkspaceLayout::default(), + ) + } + + /// List config file paths using an explicit workspace layout. + #[must_use] + pub fn config_paths_with_layout( + home_dir: Option<&str>, + astrid_home: Option<&str>, + workspace_root: Option<&str>, + workspace_layout: &WorkspaceLayout, ) -> Vec { let mut paths = Vec::new(); @@ -143,9 +161,17 @@ impl ResolvedConfig { } if let Some(ws) = workspace_root { - paths.push(format!("{ws}/.astrid/config.toml")); + paths.push( + workspace_layout + .config_path(std::path::Path::new(ws)) + .display() + .to_string(), + ); } else { - paths.push("{workspace}/.astrid/config.toml".to_owned()); + paths.push(format!( + "{{workspace}}/{}/config.toml", + workspace_layout.state_dir_name() + )); } paths @@ -234,4 +260,19 @@ mod tests { assert_eq!(paths[1], "/tmp/astrid-home/config.toml"); assert!(!paths[1].contains("/home/user/.astrid")); } + + #[test] + fn config_paths_use_injected_workspace_layout() { + let layout = WorkspaceLayout::new(".alternate-runtime").unwrap(); + let paths = ResolvedConfig::config_paths_with_layout( + Some("/home/user"), + None, + Some("/home/user/project"), + &layout, + ); + assert_eq!( + paths[2], + "/home/user/project/.alternate-runtime/config.toml" + ); + } } diff --git a/crates/astrid-core/src/dirs.rs b/crates/astrid-core/src/dirs.rs index 176c04e05..a301d785c 100644 --- a/crates/astrid-core/src/dirs.rs +++ b/crates/astrid-core/src/dirs.rs @@ -6,8 +6,8 @@ //! Linux FHS-aligned layout with `etc/`, `var/`, `run/`, `log/`, `keys/`, //! `bin/`, `lib/`, and `home/` for multi-principal isolation. //! -//! - [`WorkspaceDir`]: Per-project directory at `/.astrid/`. -//! Holds only committable project-level config (like `.astrid/ASTRID.md`). +//! - [`WorkspaceDir`]: Selected per-project state directory. +//! Holds project configuration, capsules, hooks, and instructions. //! Contains a `workspace-id` UUID that links the project to its global state. //! //! - [`PrincipalHome`]: Per-principal home directory under `~/.astrid/home/{id}/`. @@ -47,14 +47,20 @@ //! └── .config/ //! └── env/ capsule config overrides //! -//! /.astrid/ (WorkspaceDir) +//! // (WorkspaceDir) //! ├── workspace-id UUID linking project to global state -//! └── ASTRID.md project-level instructions +//! ├── config.toml project configuration +//! ├── capsules/ project-installed capsules +//! ├── hooks/ project hooks +//! └── ASTRID.md project instructions //! ``` +use std::fmt; use std::io; use std::path::{Component, Path, PathBuf}; +use std::str::FromStr; +use sha2::{Digest, Sha256}; use uuid::Uuid; use crate::principal::PrincipalId; @@ -62,6 +68,194 @@ use crate::principal::PrincipalId; /// Current layout version. Written to `etc/layout-version` on first boot. pub const LAYOUT_VERSION: &str = "1"; +/// Default per-project runtime state directory. +pub const DEFAULT_WORKSPACE_STATE_DIR: &str = ".astrid"; + +/// Validated per-project runtime layout. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct WorkspaceLayout { + state_dir_name: String, +} + +impl WorkspaceLayout { + /// Create a layout from one portable relative directory name. + /// + /// # Errors + /// + /// Returns an error for empty names, absolute paths, traversal, + /// separators, control characters, or non-portable characters. + pub fn new(name: impl Into) -> Result { + let name = name.into(); + if name.is_empty() { + return Err(WorkspaceLayoutError::Empty); + } + if name == "." || name == ".." { + return Err(WorkspaceLayoutError::Ambiguous(name)); + } + if name.len() > 64 { + return Err(WorkspaceLayoutError::TooLong); + } + if name.ends_with('.') { + return Err(WorkspaceLayoutError::Ambiguous(name)); + } + if name.contains('/') || name.contains('\\') { + return Err(WorkspaceLayoutError::Separator); + } + if !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + return Err(WorkspaceLayoutError::InvalidCharacter); + } + + let portable_stem = name.trim_start_matches('.').split('.').next().unwrap_or(""); + let upper = portable_stem.to_ascii_uppercase(); + if matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || upper.strip_prefix("COM").is_some_and(|suffix| { + matches!(suffix, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9") + }) + || upper.strip_prefix("LPT").is_some_and(|suffix| { + matches!(suffix, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9") + }) + { + return Err(WorkspaceLayoutError::Reserved(name)); + } + + let path = Path::new(&name); + let mut components = path.components(); + if path.is_absolute() + || !matches!(components.next(), Some(Component::Normal(_))) + || components.next().is_some() + { + return Err(WorkspaceLayoutError::Ambiguous(name)); + } + + Ok(Self { + state_dir_name: name, + }) + } + + /// Relative directory name used for project state. + #[must_use] + pub fn state_dir_name(&self) -> &str { + &self.state_dir_name + } + + /// Project state directory under `project_root`. + #[must_use] + pub fn state_dir(&self, project_root: &Path) -> PathBuf { + project_root.join(&self.state_dir_name) + } + + /// Workspace capsule directory under `project_root`. + #[must_use] + pub fn capsules_dir(&self, project_root: &Path) -> PathBuf { + self.state_dir(project_root).join("capsules") + } + + /// Workspace configuration path under `project_root`. + #[must_use] + pub fn config_path(&self, project_root: &Path) -> PathBuf { + self.state_dir(project_root).join("config.toml") + } + + /// Workspace hooks directory under `project_root`. + #[must_use] + pub fn hooks_dir(&self, project_root: &Path) -> PathBuf { + self.state_dir(project_root).join("hooks") + } +} + +/// Stable identity for one project root and workspace layout selection. +/// +/// The identity is suitable for detecting whether a CLI and an already-running +/// daemon selected the same project. It does not expose the project path. +#[must_use] +pub fn workspace_selection_fingerprint( + project_root: &Path, + workspace_layout: &WorkspaceLayout, +) -> String { + let root = std::fs::canonicalize(project_root).unwrap_or_else(|_| { + if project_root.is_absolute() { + project_root.to_path_buf() + } else { + std::env::current_dir() + .map(|cwd| cwd.join(project_root)) + .unwrap_or_else(|_| project_root.to_path_buf()) + } + }); + let mut hasher = Sha256::new(); + hasher.update(b"astrid-workspace-selection-v1\0"); + hash_path(&mut hasher, &root); + hasher.update(b"\0"); + hasher.update(workspace_layout.state_dir_name().as_bytes()); + hex::encode(hasher.finalize()) +} + +fn hash_path(hasher: &mut Sha256, path: &Path) { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt as _; + hasher.update(path.as_os_str().as_bytes()); + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt as _; + for unit in path.as_os_str().encode_wide() { + hasher.update(unit.to_le_bytes()); + } + } + #[cfg(not(any(unix, windows)))] + hasher.update(path.as_os_str().to_string_lossy().as_bytes()); +} + +impl Default for WorkspaceLayout { + fn default() -> Self { + Self { + state_dir_name: DEFAULT_WORKSPACE_STATE_DIR.to_owned(), + } + } +} + +impl fmt::Display for WorkspaceLayout { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.state_dir_name) + } +} + +impl FromStr for WorkspaceLayout { + type Err = WorkspaceLayoutError; + + fn from_str(value: &str) -> Result { + Self::new(value) + } +} + +/// Invalid workspace layout input. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum WorkspaceLayoutError { + /// The name is empty. + #[error("workspace state directory name must not be empty")] + Empty, + /// The name is `.` or `..`, or does not resolve to one directory component. + #[error("workspace state directory name is ambiguous: {0:?}")] + Ambiguous(String), + /// The name contains a path separator. + #[error("workspace state directory name must not contain path separators")] + Separator, + /// The name contains a non-portable character. + #[error( + "workspace state directory name may contain only ASCII letters, digits, '.', '_', and '-'" + )] + InvalidCharacter, + /// The name exceeds the portable length bound. + #[error("workspace state directory name must be at most 64 bytes")] + TooLong, + /// The name is reserved by a supported filesystem. + #[error("workspace state directory name is reserved: {0:?}")] + Reserved(String), +} + /// Reject paths containing `..` (parent directory) components. fn reject_parent_traversal(path: &Path, var_name: &str) -> io::Result<()> { if path.components().any(|c| matches!(c, Component::ParentDir)) { @@ -526,26 +720,33 @@ impl PrincipalHome { // ── WorkspaceDir (per-project) ─────────────────────────────────────────── -/// Per-project workspace directory (`/.astrid/`). +/// Selected per-project workspace state directory. /// -/// Contains only committable project-level config. A `workspace-id` UUID -/// links the project to its global state in `~/.astrid/`. +/// Contains project-local runtime state. A `workspace-id` UUID links the +/// project to its global state in `~/.astrid/`. #[derive(Debug, Clone)] pub struct WorkspaceDir { - /// The project root (parent of `.astrid/`). + /// The project root containing the selected state directory. project_root: PathBuf, + layout: WorkspaceLayout, } impl WorkspaceDir { /// Detect the workspace directory by walking up from `start_dir`. /// /// Detection order: - /// 1. Directory containing `.astrid/` + /// 1. Directory containing the selected state directory /// 2. Directory containing `.git` /// 3. Directory containing `ASTRID.md` /// 4. Fallback to `start_dir` itself #[must_use] pub fn detect(start_dir: &Path) -> Self { + Self::detect_with_layout(start_dir, WorkspaceLayout::default()) + } + + /// Detect the workspace directory using `layout`. + #[must_use] + pub fn detect_with_layout(start_dir: &Path, layout: WorkspaceLayout) -> Self { let start = if start_dir.is_absolute() { start_dir.to_path_buf() } else { @@ -555,19 +756,22 @@ impl WorkspaceDir { let mut current = start.as_path(); loop { - if current.join(".astrid").is_dir() { + if layout.state_dir(current).is_dir() { return Self { project_root: current.to_path_buf(), + layout, }; } if current.join(".git").exists() { return Self { project_root: current.to_path_buf(), + layout, }; } if current.join("ASTRID.md").exists() { return Self { project_root: current.to_path_buf(), + layout, }; } match current.parent() { @@ -578,18 +782,29 @@ impl WorkspaceDir { Self { project_root: start, + layout, } } /// Create from an explicit project root (useful for testing). #[must_use] pub fn from_path(project_root: impl Into) -> Self { + Self::from_path_with_layout(project_root, WorkspaceLayout::default()) + } + + /// Create from an explicit project root and layout. + #[must_use] + pub fn from_path_with_layout( + project_root: impl Into, + layout: WorkspaceLayout, + ) -> Self { Self { project_root: project_root.into(), + layout, } } - /// Ensure the `.astrid/` directory exists and generate a workspace ID + /// Ensure the selected state directory exists and generate a workspace ID /// if one does not already exist. /// /// # Errors @@ -601,25 +816,37 @@ impl WorkspaceDir { Ok(()) } - /// Project root directory (parent of `.astrid/`). + /// Project root directory containing the selected state directory. #[must_use] pub fn root(&self) -> &Path { &self.project_root } - /// The `.astrid/` directory itself. + /// The selected project state directory. #[must_use] pub fn dot_astrid(&self) -> PathBuf { - self.project_root.join(".astrid") + self.layout.state_dir(&self.project_root) + } + + /// The active per-project runtime state directory. + #[must_use] + pub fn state_dir(&self) -> PathBuf { + self.layout.state_dir(&self.project_root) + } + + /// The active workspace layout. + #[must_use] + pub fn layout(&self) -> &WorkspaceLayout { + &self.layout } - /// Workspace capsules directory (`.astrid/capsules/`). + /// Capsules under the selected project state directory. #[must_use] pub fn capsules_dir(&self) -> PathBuf { self.dot_astrid().join("capsules") } - /// Path to the workspace-id file (`.astrid/workspace-id`). + /// Path to the workspace-id file under selected project state. #[must_use] pub fn workspace_id_path(&self) -> PathBuf { self.dot_astrid().join("workspace-id") @@ -647,7 +874,7 @@ impl WorkspaceDir { Ok(id) } - /// Path to the project-level instructions file (`.astrid/ASTRID.md`). + /// Path to project instructions under selected project state. #[must_use] pub fn instructions_path(&self) -> PathBuf { self.dot_astrid().join("ASTRID.md") diff --git a/crates/astrid-core/src/dirs_tests.rs b/crates/astrid-core/src/dirs_tests.rs index 214403644..c12294907 100644 --- a/crates/astrid-core/src/dirs_tests.rs +++ b/crates/astrid-core/src/dirs_tests.rs @@ -250,6 +250,90 @@ fn test_principal_home_ensure_idempotent() { // ── WorkspaceDir ───────────────────────────────────────────────── +#[test] +fn workspace_layout_defaults_to_dot_astrid() { + let layout = WorkspaceLayout::default(); + assert_eq!(layout.state_dir_name(), ".astrid"); + assert_eq!( + layout.capsules_dir(Path::new("/project")), + PathBuf::from("/project/.astrid/capsules") + ); +} + +#[test] +fn workspace_layout_accepts_one_portable_directory_name() { + let layout = WorkspaceLayout::new(".alternate-runtime").unwrap(); + assert_eq!( + layout.config_path(Path::new("/project")), + PathBuf::from("/project/.alternate-runtime/config.toml") + ); +} + +#[test] +fn workspace_layout_rejects_ambiguous_or_unsafe_names() { + for value in [ + "", + ".", + "..", + "/absolute", + "nested/path", + "nested\\path", + "../escape", + "name with spaces", + "drive:name", + ".trailing.", + "CON", + "nul.txt", + "COM1", + ".LPT9", + ] { + assert!( + WorkspaceLayout::new(value).is_err(), + "{value:?} must be rejected" + ); + } +} + +#[test] +fn workspace_selection_identity_covers_root_and_layout() { + let root_a = tempfile::tempdir().unwrap(); + let root_b = tempfile::tempdir().unwrap(); + let default = WorkspaceLayout::default(); + let alternate = WorkspaceLayout::new(".alternate-runtime").unwrap(); + + let selected = workspace_selection_fingerprint(root_a.path(), &default); + assert_eq!( + selected, + workspace_selection_fingerprint(root_a.path(), &default) + ); + assert_ne!( + selected, + workspace_selection_fingerprint(root_a.path(), &alternate) + ); + assert_ne!( + selected, + workspace_selection_fingerprint(root_b.path(), &default) + ); +} + +#[test] +fn workspace_detect_uses_only_the_selected_state_directory() { + let dir = tempfile::tempdir().unwrap(); + let default_root = dir.path().join("default"); + let alternate_root = default_root.join("nested"); + std::fs::create_dir_all(default_root.join(".astrid")).unwrap(); + std::fs::create_dir_all(alternate_root.join(".alternate-runtime")).unwrap(); + let start = alternate_root.join("src"); + std::fs::create_dir_all(&start).unwrap(); + + let alternate = WorkspaceLayout::new(".alternate-runtime").unwrap(); + assert_eq!( + WorkspaceDir::detect_with_layout(&start, alternate).root(), + alternate_root + ); + assert_eq!(WorkspaceDir::detect(&start).root(), default_root); +} + #[test] fn test_workspace_detect_with_dot_astrid() { let dir = tempfile::tempdir().unwrap(); @@ -364,3 +448,21 @@ fn test_workspace_path_accessors() { PathBuf::from("/home/user/project/.astrid/ASTRID.md") ); } + +#[test] +fn workspace_path_accessors_use_injected_layout() { + let layout = WorkspaceLayout::new(".alternate-runtime").unwrap(); + let ws = WorkspaceDir::from_path_with_layout("/home/user/project", layout); + assert_eq!( + ws.state_dir(), + PathBuf::from("/home/user/project/.alternate-runtime") + ); + assert_eq!( + ws.capsules_dir(), + PathBuf::from("/home/user/project/.alternate-runtime/capsules") + ); + assert_eq!( + ws.workspace_id_path(), + PathBuf::from("/home/user/project/.alternate-runtime/workspace-id") + ); +} diff --git a/crates/astrid-daemon/src/lib.rs b/crates/astrid-daemon/src/lib.rs index 0999d87e9..d06505af2 100644 --- a/crates/astrid-daemon/src/lib.rs +++ b/crates/astrid-daemon/src/lib.rs @@ -149,16 +149,27 @@ fn resolve_http_limits(cfg: Option<&astrid_config::Config>) -> astrid_capsule::H )] pub async fn run() -> Result<()> { let args = Args::parse(); + let workspace_layout = match std::env::var("ASTRID_WORKSPACE_STATE_DIR") { + Ok(value) => astrid_core::dirs::WorkspaceLayout::new(value) + .context("invalid ASTRID_WORKSPACE_STATE_DIR")?, + Err(std::env::VarError::NotPresent) => astrid_core::dirs::WorkspaceLayout::default(), + Err(std::env::VarError::NotUnicode(_)) => { + anyhow::bail!("ASTRID_WORKSPACE_STATE_DIR must be valid UTF-8") + }, + }; let astrid_home = astrid_core::dirs::AstridHome::resolve().context("Failed to resolve Astrid home")?; + let workspace_root = args.workspace.clone().unwrap_or_else(|| { + std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")) + }); + // Load the unified config once: it drives both logging and the capsule - // runtime concurrency ceilings below. Loaded against the current dir (as - // logging always did), independent of `--workspace`. - let workspace_root_for_cfg = std::env::current_dir().ok(); - let unified_cfg = astrid_config::Config::load_with_home( - workspace_root_for_cfg.as_deref(), + // runtime concurrency ceilings below. + let unified_cfg = astrid_config::Config::load_with_home_and_layout( + Some(&workspace_root), astrid_home.root(), + &workspace_layout, ) .ok() .map(|r| r.config); @@ -175,10 +186,6 @@ pub async fn run() -> Result<()> { // Done before `args.workspace` is consumed below. let runtime_limits = resolve_capsule_limits(&args, unified_cfg.as_ref()); - let ws = args.workspace.unwrap_or_else(|| { - std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")) - }); - // Operator-approved per-capsule local-egress allowlist (SSRF-airlock // exemptions). Operator config only — the kernel hands each capsule its // own slice at load time. Absent config = empty = no exemptions. @@ -190,12 +197,13 @@ pub async fn run() -> Result<()> { // Operator ceilings for the astrid:http host (global; absent `[http]` // config = the host's historical constants). Forwarded to every capsule. let http_limits = resolve_http_limits(unified_cfg.as_ref()); - let kernel = astrid_kernel::Kernel::new( + let kernel = astrid_kernel::Kernel::new_with_workspace_layout( session_id.clone(), - ws, + workspace_root, runtime_limits, local_egress, http_limits, + workspace_layout, ) .await .map_err(|e| anyhow::anyhow!("Failed to boot Kernel: {e}"))?; @@ -247,7 +255,11 @@ pub async fn run() -> Result<()> { // Signal readiness AFTER the default CLI/system view is loaded and // accepting connections. The CLI polls for this file to avoid connecting // before the handshake accept loop is running. - astrid_kernel::socket::write_readiness_file().map_err(|e| { + astrid_kernel::socket::write_readiness_file_for_workspace( + &kernel.workspace_root, + kernel.workspace_layout(), + ) + .map_err(|e| { anyhow::anyhow!( "Failed to write readiness file \ (daemon is useless without it): {e}" @@ -363,6 +375,8 @@ fn spawn_gateway( let capability_kernel = std::sync::Arc::clone(kernel); let readiness_probe = kernel.agent_readiness_probe(); let topic_probe = kernel.capsule_topic_probe_with_warm(); + let workspace_root = kernel.workspace_root.clone(); + let workspace_layout = kernel.workspace_layout().clone(); let state = astrid_gateway::GatewayState::new( cfg, Some(bus), @@ -383,8 +397,14 @@ fn spawn_gateway( capability: &str| { capability_kernel.runtime_capability_allows(principal, device_key_id, capability) }; - if let Err(e) = - astrid_gateway::run_with_capability_probe(state, shutdown, capability_probe).await + if let Err(e) = astrid_gateway::run_with_workspace_and_capability_probe( + state, + shutdown, + workspace_root, + workspace_layout, + capability_probe, + ) + .await { tracing::error!(error = %e, "astrid-gateway exited with error"); } diff --git a/crates/astrid-gateway/src/lib.rs b/crates/astrid-gateway/src/lib.rs index e9341a69f..a9011fac4 100644 --- a/crates/astrid-gateway/src/lib.rs +++ b/crates/astrid-gateway/src/lib.rs @@ -82,7 +82,12 @@ pub async fn run( state: Arc, shutdown: impl Future + Send + 'static, ) -> Result<()> { - run_inner(state, shutdown, routes::events::CapabilityProbe::deny_all()).await + run_with_workspace_layout( + state, + shutdown, + astrid_core::dirs::WorkspaceLayout::default(), + ) + .await } /// Run the gateway with a borrowed in-process capability evaluator. @@ -94,12 +99,66 @@ pub async fn run_with_capability_probe( shutdown: impl Future + Send + 'static, capability_probe: F, ) -> Result<()> +where + F: Fn(&astrid_core::PrincipalId, Option<&str>, &str) -> bool + Send + Sync + 'static, +{ + let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + run_inner( + state, + shutdown, + workspace_root, + astrid_core::dirs::WorkspaceLayout::default(), + routes::events::CapabilityProbe::new(capability_probe), + ) + .await +} + +/// Run the gateway with an explicit per-project runtime layout. +pub async fn run_with_workspace_layout( + state: Arc, + shutdown: impl Future + Send + 'static, + workspace_layout: astrid_core::dirs::WorkspaceLayout, +) -> Result<()> { + let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + run_with_workspace(state, shutdown, workspace_root, workspace_layout).await +} + +/// Run the gateway with explicit workspace inputs. +pub async fn run_with_workspace( + state: Arc, + shutdown: impl Future + Send + 'static, + workspace_root: std::path::PathBuf, + workspace_layout: astrid_core::dirs::WorkspaceLayout, +) -> Result<()> { + run_inner( + state, + shutdown, + workspace_root, + workspace_layout, + routes::events::CapabilityProbe::deny_all(), + ) + .await +} + +/// Run the gateway with explicit workspace inputs and an in-process capability evaluator. +/// +/// # Errors +/// Returns the same startup and server errors as [`run`]. +pub async fn run_with_workspace_and_capability_probe( + state: Arc, + shutdown: impl Future + Send + 'static, + workspace_root: std::path::PathBuf, + workspace_layout: astrid_core::dirs::WorkspaceLayout, + capability_probe: F, +) -> Result<()> where F: Fn(&astrid_core::PrincipalId, Option<&str>, &str) -> bool + Send + Sync + 'static, { run_inner( state, shutdown, + workspace_root, + workspace_layout, routes::events::CapabilityProbe::new(capability_probe), ) .await @@ -127,10 +186,13 @@ where .local_addr() .context("failed to read pre-bound gateway listener address")?; warn_if_plaintext_non_loopback(addr); + let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); serve_http_listener( state, listener, shutdown, + workspace_root, + astrid_core::dirs::WorkspaceLayout::default(), routes::events::CapabilityProbe::new(capability_probe), ) .await @@ -139,6 +201,8 @@ where async fn run_inner( state: Arc, shutdown: impl Future + Send + 'static, + workspace_root: std::path::PathBuf, + workspace_layout: astrid_core::dirs::WorkspaceLayout, capability_probe: routes::events::CapabilityProbe, ) -> Result<()> { let addr: SocketAddr = state.config.listen.parse().with_context(|| { @@ -154,7 +218,12 @@ async fn run_inner( // doesn't apply here — axum-server opens its own listener. info!(addr = %addr, scheme = "https", "astrid-gateway listening (TLS)"); let rustls = tls::load_rustls_config(tls_cfg).await?; - let router = tls::apply_hsts(routes::build_with_probe(state, capability_probe)); + let router = tls::apply_hsts(routes::build_with_workspace_and_probe( + state, + workspace_root, + workspace_layout, + capability_probe, + )); return tls::serve_https(addr, router, rustls, shutdown).await; } @@ -163,7 +232,15 @@ async fn run_inner( let listener = TcpListener::bind(addr) .await .with_context(|| format!("failed to bind gateway listener on {addr}"))?; - serve_http_listener(state, listener, shutdown, capability_probe).await + serve_http_listener( + state, + listener, + shutdown, + workspace_root, + workspace_layout, + capability_probe, + ) + .await } fn warn_if_plaintext_non_loopback(addr: SocketAddr) { @@ -179,6 +256,8 @@ async fn serve_http_listener( state: Arc, listener: TcpListener, shutdown: impl Future + Send + 'static, + workspace_root: std::path::PathBuf, + workspace_layout: astrid_core::dirs::WorkspaceLayout, capability_probe: routes::events::CapabilityProbe, ) -> Result<()> { let bound = listener @@ -200,7 +279,12 @@ async fn serve_http_listener( ); } - let router = routes::build_with_probe(state, capability_probe); + let router = routes::build_with_workspace_and_probe( + state, + workspace_root, + workspace_layout, + capability_probe, + ); // `into_make_service_with_connect_info::()` is what // populates the `ConnectInfo` request extension that // `routes::auth::post_redeem` extracts for per-IP rate limiting. diff --git a/crates/astrid-gateway/src/routes/capsule_sources.rs b/crates/astrid-gateway/src/routes/capsule_sources.rs index 589f5c907..c6e1ad6ca 100644 --- a/crates/astrid-gateway/src/routes/capsule_sources.rs +++ b/crates/astrid-gateway/src/routes/capsule_sources.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use std::path::{Path, PathBuf}; use astrid_core::PrincipalId; -use astrid_core::dirs::AstridHome; +use astrid_core::dirs::{AstridHome, WorkspaceLayout}; use serde::Deserialize; use uuid::Uuid; @@ -27,7 +27,12 @@ pub(super) fn capsule_source_id_v1(capsule_id: &str, content_hash: &str) -> Uuid Uuid::new_v5(&CAPSULE_ID_NAMESPACE, seed.as_bytes()) } -pub(super) fn trusted_capsule_source_ids(capsule_id: &str, caller: &PrincipalId) -> Vec { +pub(super) fn trusted_capsule_source_ids( + capsule_id: &str, + caller: &PrincipalId, + workspace_root: &Path, + workspace_layout: &WorkspaceLayout, +) -> Vec { let Ok(home) = AstridHome::resolve() else { return Vec::new(); }; @@ -38,9 +43,11 @@ pub(super) fn trusted_capsule_source_ids(capsule_id: &str, caller: &PrincipalId) // principals (issue #1069). let mut dirs = Vec::new(); dirs.push(home.principal_home(caller).capsules_dir().join(capsule_id)); - if let Ok(cwd) = std::env::current_dir() { - dirs.push(cwd.join(".astrid").join("capsules").join(capsule_id)); - } + dirs.push( + workspace_layout + .capsules_dir(workspace_root) + .join(capsule_id), + ); trusted_capsule_source_ids_from_dirs(capsule_id, dirs) } diff --git a/crates/astrid-gateway/src/routes/mod.rs b/crates/astrid-gateway/src/routes/mod.rs index 317493a85..4244f7d7c 100644 --- a/crates/astrid-gateway/src/routes/mod.rs +++ b/crates/astrid-gateway/src/routes/mod.rs @@ -15,6 +15,21 @@ use astrid_uplink::KernelClientError; use crate::error::GatewayError; use crate::state::GatewayState; +#[derive(Clone)] +pub(crate) struct WorkspaceContext { + pub(crate) root: std::path::PathBuf, + pub(crate) layout: astrid_core::dirs::WorkspaceLayout, +} + +impl Default for WorkspaceContext { + fn default() -> Self { + Self { + root: std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), + layout: astrid_core::dirs::WorkspaceLayout::default(), + } + } +} + /// Map a bus-direct / socket kernel-request failure ([`KernelClientError`]) to a /// [`GatewayError`]. Single-sourced so every `kernel_client_for(...).request()` /// call site maps consistently. @@ -66,7 +81,36 @@ pub mod system; // rows here. Splitting it into sub-routers would obscure the single // public/authed grouping for no readability gain. pub fn build(state: Arc) -> Router { - build_with_probe(state, events::CapabilityProbe::deny_all()) + build_with_workspace_layout(state, astrid_core::dirs::WorkspaceLayout::default()) +} + +/// Build the gateway router with an explicit workspace layout and self-only +/// audit visibility. +/// +/// This has the same real-socket connect-info requirement as [`build`]. +pub fn build_with_workspace_layout( + state: Arc, + workspace_layout: astrid_core::dirs::WorkspaceLayout, +) -> Router { + let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + build_with_workspace(state, workspace_root, workspace_layout) +} + +/// Build the gateway router with explicit workspace inputs and self-only +/// audit visibility. +/// +/// This has the same real-socket connect-info requirement as [`build`]. +pub fn build_with_workspace( + state: Arc, + workspace_root: std::path::PathBuf, + workspace_layout: astrid_core::dirs::WorkspaceLayout, +) -> Router { + build_with_workspace_and_probe( + state, + workspace_root, + workspace_layout, + events::CapabilityProbe::deny_all(), + ) } /// Build the gateway's HTTP router with an in-process capability evaluator. @@ -83,12 +127,42 @@ pub fn build_with_capability_probe(state: Arc, capability_probe where F: Fn(&astrid_core::PrincipalId, Option<&str>, &str) -> bool + Send + Sync + 'static, { - build_with_probe(state, events::CapabilityProbe::new(capability_probe)) + let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + build_with_workspace_and_probe( + state, + workspace_root, + astrid_core::dirs::WorkspaceLayout::default(), + events::CapabilityProbe::new(capability_probe), + ) +} + +/// Build the gateway router with explicit workspace inputs and an in-process +/// capability evaluator. +/// +/// This is the fully composed direct-embedding path. It has the same +/// real-socket connect-info requirement as [`build`]. +pub fn build_with_workspace_and_capability_probe( + state: Arc, + workspace_root: std::path::PathBuf, + workspace_layout: astrid_core::dirs::WorkspaceLayout, + capability_probe: F, +) -> Router +where + F: Fn(&astrid_core::PrincipalId, Option<&str>, &str) -> bool + Send + Sync + 'static, +{ + build_with_workspace_and_probe( + state, + workspace_root, + workspace_layout, + events::CapabilityProbe::new(capability_probe), + ) } #[allow(clippy::too_many_lines)] -pub(crate) fn build_with_probe( +pub(crate) fn build_with_workspace_and_probe( state: Arc, + workspace_root: std::path::PathBuf, + workspace_layout: astrid_core::dirs::WorkspaceLayout, capability_probe: events::CapabilityProbe, ) -> Router { // Unauthenticated routes — discovery + redeem + ops probes. @@ -154,7 +228,12 @@ pub(crate) fn build_with_probe( // A future misconfigured handler that accidentally renders HTML // would be neutered rather than ship a clickjacking / XSS // surface. - apply_security_headers(with_cors).with_state(state) + apply_security_headers(with_cors) + .with_state(state) + .layer(Extension(WorkspaceContext { + root: workspace_root, + layout: workspace_layout, + })) } /// Build the bearer-gated router half. Split out of [`build`] so each stays @@ -240,20 +319,26 @@ fn build_authed_router(state: &Arc) -> Router> { // ── Per-principal live conversation feed (SSE, #973) ── .route("/api/agent/stream", get(stream::get_stream)) // ── Conversation threads (proxied to capsule-session) ── - .route("/api/agent/sessions", get(sessions::list_sessions)) + .route( + "/api/agent/sessions", + get(sessions::list_sessions_with_layout), + ) // `search` is a static segment and is registered before the `:id` // routes; axum prefers the static match, so `/sessions/search` never // collides with `/sessions/:id`. - .route("/api/agent/sessions/search", get(sessions::search_sessions)) + .route( + "/api/agent/sessions/search", + get(sessions::search_sessions_with_layout), + ) .route( "/api/agent/sessions/{id}", - get(sessions::get_session) - .patch(sessions::update_session) - .delete(sessions::delete_session), + get(sessions::get_session_with_layout) + .patch(sessions::update_session_with_layout) + .delete(sessions::delete_session_with_layout), ) .route( "/api/agent/sessions/{id}/messages", - get(sessions::get_session_messages), + get(sessions::get_session_messages_with_layout), ) // ── Agent elicitation reply ── .route( @@ -265,9 +350,15 @@ fn build_authed_router(state: &Arc) -> Router> { post(agent::post_approval_response), ) // ── Models (active-LLM selection) ── - .route("/api/models", get(models::list_models)) - .route("/api/models/active", get(models::get_active_model)) - .route("/api/models/active", put(models::set_active_model)) + .route("/api/models", get(models::list_models_with_layout)) + .route( + "/api/models/active", + get(models::get_active_model_with_layout), + ) + .route( + "/api/models/active", + put(models::set_active_model_with_layout), + ) // ── System ── .route("/api/sys/status", get(system::get_status)) .route("/api/sys/readiness", get(system::get_readiness)) diff --git a/crates/astrid-gateway/src/routes/models.rs b/crates/astrid-gateway/src/routes/models.rs index 258e0896c..e01ad2c68 100644 --- a/crates/astrid-gateway/src/routes/models.rs +++ b/crates/astrid-gateway/src/routes/models.rs @@ -41,6 +41,7 @@ use std::time::Duration; use astrid_events::AstridEvent; use astrid_events::ipc::{IpcMessage, IpcPayload, Topic}; +use axum::Extension; use axum::Json; use axum::extract::State; use axum::http::Request; @@ -49,12 +50,12 @@ use serde_json::json; use utoipa::ToSchema; use uuid::Uuid; -use astrid_core::PrincipalId; - use crate::error::{ErrorBody, GatewayError, GatewayResult}; +use crate::routes::WorkspaceContext; use crate::routes::capsule_sources::{capsule_source_id_v0, trusted_capsule_source_ids}; use crate::routes::principals::caller_from; use crate::state::GatewayState; +use astrid_core::PrincipalId; /// Wall-clock budget for the registry to answer a model request. Matches /// the 10s budget the CLI uses for the equivalent daemon read @@ -193,6 +194,7 @@ fn classify_set_active_reply(reply: &serde_json::Value) -> SetActiveOutcome { async fn registry_round_trip( state: &GatewayState, principal_id: &PrincipalId, + workspace: &WorkspaceContext, request_topic: &'static str, response_topic: &'static str, payload: serde_json::Value, @@ -256,7 +258,12 @@ async fn registry_round_trip( // `recv` is bounded by the time REMAINING, so a stream of skipped foreign // replies can never extend the total wait past the original budget. let timeout = state.registry_timeout.unwrap_or(REGISTRY_TIMEOUT); - let expected_source_ids = trusted_capsule_source_ids(REGISTRY_CAPSULE_ID, principal_id); + let expected_source_ids = trusted_capsule_source_ids( + REGISTRY_CAPSULE_ID, + principal_id, + &workspace.root, + &workspace.layout, + ); let expected_source_ids = if expected_source_ids.is_empty() { tracing::warn!( capsule_id = REGISTRY_CAPSULE_ID, @@ -355,11 +362,28 @@ fn scoped_topic_probe_key(principal: &PrincipalId, capsule_id: &str, topic: &str pub async fn list_models( State(state): State>, req: Request, +) -> GatewayResult> { + list_models_inner(state, &WorkspaceContext::default(), req).await +} + +pub(crate) async fn list_models_with_layout( + State(state): State>, + Extension(workspace): Extension, + req: Request, +) -> GatewayResult> { + list_models_inner(state, &workspace, req).await +} + +async fn list_models_inner( + state: Arc, + workspace: &WorkspaceContext, + req: Request, ) -> GatewayResult> { let caller = caller_from(&req)?; let reply = registry_round_trip( &state, &caller.principal, + workspace, GET_PROVIDERS_REQUEST, GET_PROVIDERS_RESPONSE, json!({}), @@ -385,11 +409,28 @@ pub async fn list_models( pub async fn get_active_model( State(state): State>, req: Request, +) -> GatewayResult> { + get_active_model_inner(state, &WorkspaceContext::default(), req).await +} + +pub(crate) async fn get_active_model_with_layout( + State(state): State>, + Extension(workspace): Extension, + req: Request, +) -> GatewayResult> { + get_active_model_inner(state, &workspace, req).await +} + +async fn get_active_model_inner( + state: Arc, + workspace: &WorkspaceContext, + req: Request, ) -> GatewayResult> { let caller = caller_from(&req)?; let reply = registry_round_trip( &state, &caller.principal, + workspace, GET_ACTIVE_REQUEST, GET_ACTIVE_RESPONSE, json!({}), @@ -417,6 +458,22 @@ pub async fn get_active_model( pub async fn set_active_model( State(state): State>, req: Request, +) -> GatewayResult> { + set_active_model_inner(state, &WorkspaceContext::default(), req).await +} + +pub(crate) async fn set_active_model_with_layout( + State(state): State>, + Extension(workspace): Extension, + req: Request, +) -> GatewayResult> { + set_active_model_inner(state, &workspace, req).await +} + +async fn set_active_model_inner( + state: Arc, + workspace: &WorkspaceContext, + req: Request, ) -> GatewayResult> { // Clone only the principal id, not the whole `CallerContext`: the // round-trip needs nothing else, and `read_json_body` below consumes @@ -444,6 +501,7 @@ pub async fn set_active_model( let reply = registry_round_trip( &state, &principal, + workspace, SET_ACTIVE_REQUEST, SET_ACTIVE_RESPONSE, // The registry reads `model_id` (also accepted under `data`); the @@ -609,6 +667,7 @@ mod tests { let err = registry_round_trip( &state, &principal, + &WorkspaceContext::default(), GET_ACTIVE_REQUEST, GET_ACTIVE_RESPONSE, json!({}), @@ -668,6 +727,7 @@ mod tests { let reply = registry_round_trip( &state, &principal, + &WorkspaceContext::default(), GET_ACTIVE_REQUEST, GET_ACTIVE_RESPONSE, json!({}), diff --git a/crates/astrid-gateway/src/routes/sessions.rs b/crates/astrid-gateway/src/routes/sessions.rs index 9018e8496..8c6bf1ec6 100644 --- a/crates/astrid-gateway/src/routes/sessions.rs +++ b/crates/astrid-gateway/src/routes/sessions.rs @@ -33,6 +33,7 @@ use std::time::Duration; use astrid_core::PrincipalId; use astrid_events::ipc::{IpcMessage, IpcPayload, MessageOrigin, Topic}; use astrid_events::{AstridEvent, EventBus, EventMetadata}; +use axum::Extension; use axum::Json; use axum::extract::{Path, Query, State}; use axum::http::Request; @@ -42,6 +43,7 @@ use utoipa::ToSchema; use uuid::Uuid; use crate::error::{ErrorBody, GatewayError, GatewayResult}; +use crate::routes::WorkspaceContext; use crate::routes::capsule_sources::trusted_capsule_source_ids; use crate::routes::principals::caller_from; use crate::state::GatewayState; @@ -332,6 +334,24 @@ pub async fn list_sessions( State(state): State>, Query(query): Query, req: Request, +) -> GatewayResult> { + list_sessions_inner(state, &WorkspaceContext::default(), query, req).await +} + +pub(crate) async fn list_sessions_with_layout( + State(state): State>, + Extension(workspace): Extension, + Query(query): Query, + req: Request, +) -> GatewayResult> { + list_sessions_inner(state, &workspace, query, req).await +} + +async fn list_sessions_inner( + state: Arc, + workspace: &WorkspaceContext, + query: SessionListQuery, + req: Request, ) -> GatewayResult> { let caller = caller_from(&req)?; metrics::counter!("astrid_gateway_agent_sessions_list_total").increment(1); @@ -352,7 +372,7 @@ pub async fn list_sessions( query.include_archived.unwrap_or(false), ); let response_topic = format!("{TOPIC_LIST_RESPONSE_PREFIX}.{correlation_id}"); - let expected_sources = session_capsule_source_ids(&caller.principal); + let expected_sources = session_capsule_source_ids(&caller.principal, workspace); let value = request_capsule( &bus, @@ -399,6 +419,24 @@ pub async fn get_session_messages( State(state): State>, Path(id): Path, req: Request, +) -> GatewayResult> { + get_session_messages_inner(state, &WorkspaceContext::default(), id, req).await +} + +pub(crate) async fn get_session_messages_with_layout( + State(state): State>, + Extension(workspace): Extension, + Path(id): Path, + req: Request, +) -> GatewayResult> { + get_session_messages_inner(state, &workspace, id, req).await +} + +async fn get_session_messages_inner( + state: Arc, + workspace: &WorkspaceContext, + id: String, + req: Request, ) -> GatewayResult> { let caller = caller_from(&req)?; metrics::counter!("astrid_gateway_agent_sessions_transcript_total").increment(1); @@ -408,7 +446,7 @@ pub async fn get_session_messages( let correlation_id = Uuid::new_v4().to_string(); let payload = build_messages_payload(&id, &correlation_id); let response_topic = format!("{TOPIC_MESSAGES_RESPONSE_PREFIX}.{correlation_id}"); - let expected_sources = session_capsule_source_ids(&caller.principal); + let expected_sources = session_capsule_source_ids(&caller.principal, workspace); let value = request_capsule( &bus, @@ -459,6 +497,24 @@ pub async fn get_session( State(state): State>, Path(id): Path, req: Request, +) -> GatewayResult> { + get_session_inner(state, &WorkspaceContext::default(), id, req).await +} + +pub(crate) async fn get_session_with_layout( + State(state): State>, + Extension(workspace): Extension, + Path(id): Path, + req: Request, +) -> GatewayResult> { + get_session_inner(state, &workspace, id, req).await +} + +async fn get_session_inner( + state: Arc, + workspace: &WorkspaceContext, + id: String, + req: Request, ) -> GatewayResult> { let caller = caller_from(&req)?; metrics::counter!("astrid_gateway_agent_sessions_get_meta_total").increment(1); @@ -475,7 +531,7 @@ pub async fn get_session( "session_id": id, }); let response_topic = format!("{TOPIC_GET_META_RESPONSE_PREFIX}.{correlation_id}"); - let expected_sources = session_capsule_source_ids(&caller.principal); + let expected_sources = session_capsule_source_ids(&caller.principal, workspace); let value = request_capsule( &bus, @@ -524,6 +580,24 @@ pub async fn update_session( State(state): State>, Path(id): Path, req: Request, +) -> GatewayResult> { + update_session_inner(state, &WorkspaceContext::default(), id, req).await +} + +pub(crate) async fn update_session_with_layout( + State(state): State>, + Extension(workspace): Extension, + Path(id): Path, + req: Request, +) -> GatewayResult> { + update_session_inner(state, &workspace, id, req).await +} + +async fn update_session_inner( + state: Arc, + workspace: &WorkspaceContext, + id: String, + req: Request, ) -> GatewayResult> { // Clone the principal/device floor before `read_json_body` consumes // `req` (which `caller_from` borrows), exactly as `models.rs` does. @@ -543,7 +617,7 @@ pub async fn update_session( let correlation_id = Uuid::new_v4().to_string(); let payload = build_update_payload(&correlation_id, &id, &body)?; let response_topic = format!("{TOPIC_UPDATE_RESPONSE_PREFIX}.{correlation_id}"); - let expected_sources = session_capsule_source_ids(&principal); + let expected_sources = session_capsule_source_ids(&principal, workspace); let value = request_capsule( &bus, @@ -588,6 +662,24 @@ pub async fn delete_session( State(state): State>, Path(id): Path, req: Request, +) -> GatewayResult> { + delete_session_inner(state, &WorkspaceContext::default(), id, req).await +} + +pub(crate) async fn delete_session_with_layout( + State(state): State>, + Extension(workspace): Extension, + Path(id): Path, + req: Request, +) -> GatewayResult> { + delete_session_inner(state, &workspace, id, req).await +} + +async fn delete_session_inner( + state: Arc, + workspace: &WorkspaceContext, + id: String, + req: Request, ) -> GatewayResult> { let caller = caller_from(&req)?; metrics::counter!("astrid_gateway_agent_sessions_delete_total").increment(1); @@ -601,7 +693,7 @@ pub async fn delete_session( "session_id": id, }); let response_topic = format!("{TOPIC_DELETE_RESPONSE_PREFIX}.{correlation_id}"); - let expected_sources = session_capsule_source_ids(&caller.principal); + let expected_sources = session_capsule_source_ids(&caller.principal, workspace); let value = request_capsule( &bus, @@ -647,6 +739,24 @@ pub async fn search_sessions( State(state): State>, Query(query): Query, req: Request, +) -> GatewayResult> { + search_sessions_inner(state, &WorkspaceContext::default(), query, req).await +} + +pub(crate) async fn search_sessions_with_layout( + State(state): State>, + Extension(workspace): Extension, + Query(query): Query, + req: Request, +) -> GatewayResult> { + search_sessions_inner(state, &workspace, query, req).await +} + +async fn search_sessions_inner( + state: Arc, + workspace: &WorkspaceContext, + query: SearchQuery, + req: Request, ) -> GatewayResult> { let caller = caller_from(&req)?; metrics::counter!("astrid_gateway_agent_sessions_search_total").increment(1); @@ -664,7 +774,7 @@ pub async fn search_sessions( query.include_archived.unwrap_or(false), ); let response_topic = format!("{TOPIC_SEARCH_RESPONSE_PREFIX}.{correlation_id}"); - let expected_sources = session_capsule_source_ids(&caller.principal); + let expected_sources = session_capsule_source_ids(&caller.principal, workspace); let value = request_capsule( &bus, @@ -737,8 +847,13 @@ async fn ensure_session_mgmt_supported( } } -fn session_capsule_source_ids(principal: &PrincipalId) -> Vec { - trusted_capsule_source_ids(SESSION_CAPSULE_ID, principal) +fn session_capsule_source_ids(principal: &PrincipalId, workspace: &WorkspaceContext) -> Vec { + trusted_capsule_source_ids( + SESSION_CAPSULE_ID, + principal, + &workspace.root, + &workspace.layout, + ) } fn scoped_topic_probe_key(principal: &PrincipalId, capsule_id: &str, topic: &str) -> String { diff --git a/crates/astrid-hooks/src/discovery.rs b/crates/astrid-hooks/src/discovery.rs index acf371eaa..2bc5d203d 100644 --- a/crates/astrid-hooks/src/discovery.rs +++ b/crates/astrid-hooks/src/discovery.rs @@ -1,5 +1,6 @@ //! Hook discovery - find hooks from standard locations. +use astrid_core::dirs::WorkspaceLayout; use std::path::{Path, PathBuf}; use thiserror::Error; use tracing::{debug, info, warn}; @@ -46,22 +47,39 @@ pub(crate) const HOOK_FILE_NAMES: &[&str] = &["HOOK.toml", "hook.toml", "hooks.t /// Discover hooks from standard locations. /// /// This function looks for hooks in: -/// 1. `.astrid/hooks/` in the current directory (workspace-level) +/// 1. Hooks under the selected project state directory /// 2. Any additional paths provided in `extra_paths` /// /// Callers should pass the user-level hooks directory (e.g. /// `AstridHome::hooks_dir()`) via `extra_paths` rather than relying /// on hard-coded platform paths. pub(crate) fn discover_hooks(extra_paths: Option<&[PathBuf]>) -> Vec { + discover_hooks_with_layout(extra_paths, &WorkspaceLayout::default()) +} + +pub(crate) fn discover_hooks_with_layout( + extra_paths: Option<&[PathBuf]>, + workspace_layout: &WorkspaceLayout, +) -> Vec { + let workspace_root = std::env::current_dir().ok(); + discover_hooks_in_workspace(extra_paths, workspace_root.as_deref(), workspace_layout) +} + +pub(crate) fn discover_hooks_in_workspace( + extra_paths: Option<&[PathBuf]>, + workspace_root: Option<&Path>, + workspace_layout: &WorkspaceLayout, +) -> Vec { let mut hooks = Vec::new(); - // Look in local .astrid/hooks directory - let local_hooks_dir = PathBuf::from(".astrid/hooks"); - if local_hooks_dir.exists() { - info!(path = %local_hooks_dir.display(), "Discovering hooks from local directory"); - match load_hooks_from_dir(&local_hooks_dir) { - Ok(found) => hooks.extend(found), - Err(e) => warn!(error = %e, "Failed to load hooks from local directory"), + if let Some(workspace_root) = workspace_root { + let local_hooks_dir = workspace_layout.hooks_dir(workspace_root); + if local_hooks_dir.exists() { + info!(path = %local_hooks_dir.display(), "Discovering hooks from local directory"); + match load_hooks_from_dir(&local_hooks_dir) { + Ok(found) => hooks.extend(found), + Err(e) => warn!(error = %e, "Failed to load hooks from local directory"), + } } } @@ -188,7 +206,15 @@ pub(crate) fn save_hook(hook: &Hook, path: &Path) -> DiscoveryResult<()> { /// Hooks directory in a workspace. #[must_use] pub(crate) fn workspace_hooks_dir(workspace_root: &Path) -> PathBuf { - workspace_root.join(".astrid").join("hooks") + workspace_hooks_dir_with_layout(workspace_root, &WorkspaceLayout::default()) +} + +#[must_use] +pub(crate) fn workspace_hooks_dir_with_layout( + workspace_root: &Path, + workspace_layout: &WorkspaceLayout, +) -> PathBuf { + workspace_layout.hooks_dir(workspace_root) } #[cfg(test)] @@ -244,4 +270,31 @@ mod tests { // May find system hooks, so just check it doesn't panic let _ = hooks; } + + #[test] + fn workspace_hooks_use_injected_layout() { + let layout = WorkspaceLayout::new(".alternate-runtime").unwrap(); + assert_eq!( + workspace_hooks_dir_with_layout(Path::new("/workspace"), &layout), + PathBuf::from("/workspace/.alternate-runtime/hooks") + ); + } + + #[test] + fn discovery_uses_explicit_workspace_root() { + let workspace = TempDir::new().unwrap(); + let layout = WorkspaceLayout::new(".alternate-runtime").unwrap(); + let hooks_dir = layout.hooks_dir(workspace.path()); + std::fs::create_dir_all(&hooks_dir).unwrap(); + save_hook( + &Hook::new(HookEvent::SessionStart).with_name("selected"), + &hooks_dir.join("HOOK.toml"), + ) + .unwrap(); + + let hooks = discover_hooks_in_workspace(None, Some(workspace.path()), &layout); + + assert_eq!(hooks.len(), 1); + assert_eq!(hooks[0].name.as_deref(), Some("selected")); + } } diff --git a/crates/astrid-kernel/src/kernel_router/install.rs b/crates/astrid-kernel/src/kernel_router/install.rs index 6e753c506..695cc2332 100644 --- a/crates/astrid-kernel/src/kernel_router/install.rs +++ b/crates/astrid-kernel/src/kernel_router/install.rs @@ -93,16 +93,34 @@ pub(super) async fn handle_install_capsule( let p = path.clone(); let h = home.clone(); let principal = caller.clone(); + let workspace_layout = kernel.workspace_layout.clone(); + let workspace_root = kernel.workspace_root.clone(); tokio::task::spawn_blocking(move || { - astrid_capsule_install::unpack_and_install_for_principal(&p, &h, opts, &principal) + astrid_capsule_install::unpack_and_install_for_principal_in_workspace( + &p, + &h, + opts, + &principal, + Some(&workspace_root), + &workspace_layout, + ) }) .await } else if path.is_dir() { let p = path.clone(); let h = home.clone(); let principal = caller.clone(); + let workspace_layout = kernel.workspace_layout.clone(); + let workspace_root = kernel.workspace_root.clone(); tokio::task::spawn_blocking(move || { - astrid_capsule_install::install_from_local_path_for_principal(&p, &h, opts, &principal) + astrid_capsule_install::install_from_local_path_for_principal_in_workspace( + &p, + &h, + opts, + &principal, + Some(&workspace_root), + &workspace_layout, + ) }) .await } else { diff --git a/crates/astrid-kernel/src/kernel_router/mod.rs b/crates/astrid-kernel/src/kernel_router/mod.rs index e52219faa..b134fc340 100644 --- a/crates/astrid-kernel/src/kernel_router/mod.rs +++ b/crates/astrid-kernel/src/kernel_router/mod.rs @@ -506,9 +506,15 @@ async fn inventory_manifest_map( &kernel.astrid_home, &kernel.workspace_root, &visibility.principal, + &kernel.workspace_layout, ); + let workspace_layout = kernel.workspace_layout.clone(); let discovered = match tokio::task::spawn_blocking(move || { - astrid_capsule::discovery::discover_manifests(Some(&paths)) + astrid_capsule::discovery::discover_manifests_in_workspace( + Some(&paths), + None, + &workspace_layout, + ) }) .await { diff --git a/crates/astrid-kernel/src/lib.rs b/crates/astrid-kernel/src/lib.rs index 9ebed57d5..eb2b3c93f 100644 --- a/crates/astrid-kernel/src/lib.rs +++ b/crates/astrid-kernel/src/lib.rs @@ -55,6 +55,7 @@ use astrid_capsule::profile_cache::PrincipalProfileCache; use astrid_capsule::registry::CapsuleRegistry; use astrid_capsule_types::CapsuleId; use astrid_core::SessionId; +use astrid_core::dirs::WorkspaceLayout; use astrid_core::groups::GroupConfig; use astrid_core::principal::PrincipalId; use astrid_crypto::KeyPair; @@ -114,6 +115,8 @@ pub struct Kernel { pub vfs_root_handle: DirHandle, /// The physical path the VFS is mounted to. pub workspace_root: PathBuf, + /// Per-project runtime state layout selected at boot. + workspace_layout: WorkspaceLayout, /// The principal home resources directory (`~/.astrid/home/{principal}/`). /// Capsules declaring `fs_read = ["home://"]` can read files under this /// root. Scoped to the principal's home so that keys, databases, and @@ -335,6 +338,12 @@ impl KernelResources { } impl Kernel { + /// Per-project runtime layout selected at boot. + #[must_use] + pub fn workspace_layout(&self) -> &WorkspaceLayout { + &self.workspace_layout + } + /// Boot a new Kernel instance mounted at the specified directory. /// /// The native composition root: resolves the Astrid home, opens the @@ -374,6 +383,27 @@ impl Kernel { runtime_limits: astrid_capsule_types::CapsuleRuntimeLimits, local_egress: std::collections::HashMap>, http_limits: astrid_capsule_types::HttpLimits, + ) -> Result, std::io::Error> { + Self::new_with_workspace_layout( + session_id, + workspace_root, + runtime_limits, + local_egress, + http_limits, + WorkspaceLayout::default(), + ) + .await + } + + /// Boot a kernel with an explicit per-project runtime layout. + #[cfg(unix)] + pub async fn new_with_workspace_layout( + session_id: SessionId, + workspace_root: PathBuf, + runtime_limits: astrid_capsule_types::CapsuleRuntimeLimits, + local_egress: std::collections::HashMap>, + http_limits: astrid_capsule_types::HttpLimits, + workspace_layout: WorkspaceLayout, ) -> Result, std::io::Error> { use astrid_core::dirs::AstridHome; @@ -440,13 +470,14 @@ impl Kernel { Some(singleton_lock), ); - Self::with_resources( + Self::with_resources_and_workspace_layout( session_id, workspace_root, runtime_limits, local_egress, http_limits, resources, + workspace_layout, ) .await } @@ -485,17 +516,39 @@ impl Kernel { /// cannot be registered, the capability store cannot be initialized over /// the injected KV, the group configuration cannot be loaded, or the CLI /// root identity cannot be bootstrapped. + pub async fn with_resources( + session_id: SessionId, + workspace_root: PathBuf, + runtime_limits: astrid_capsule_types::CapsuleRuntimeLimits, + local_egress: std::collections::HashMap>, + http_limits: astrid_capsule_types::HttpLimits, + resources: KernelResources, + ) -> Result, std::io::Error> { + Self::with_resources_and_workspace_layout( + session_id, + workspace_root, + runtime_limits, + local_egress, + http_limits, + resources, + WorkspaceLayout::default(), + ) + .await + } + + /// Construct a kernel from injected resources and workspace layout. #[expect( clippy::too_many_lines, reason = "boot sequence: sequential setup that does not benefit from splitting" )] - pub async fn with_resources( + pub async fn with_resources_and_workspace_layout( session_id: SessionId, workspace_root: PathBuf, runtime_limits: astrid_capsule_types::CapsuleRuntimeLimits, local_egress: std::collections::HashMap>, http_limits: astrid_capsule_types::HttpLimits, resources: KernelResources, + workspace_layout: WorkspaceLayout, ) -> Result, std::io::Error> { // The native capsule engine uses `block_in_place`, which requires a // multi-thread runtime. The browser profile has no such runtime (and no @@ -621,7 +674,7 @@ impl Kernel { })?; // Apply pre-configured identity links from config. - apply_identity_config(&identity_store, &workspace_root).await; + apply_identity_config(&identity_store, &workspace_root, &workspace_layout).await; } let kernel = Arc::new(Self { @@ -637,6 +690,7 @@ impl Kernel { overlay_registry, vfs_root_handle: root_handle, workspace_root, + workspace_layout, home_root, cli_socket_listener, singleton_lock, @@ -1269,8 +1323,17 @@ impl Kernel { ) -> Vec<(astrid_capsule_types::manifest::CapsuleManifest, PathBuf)> { use astrid_capsule::toposort::toposort_manifests; - let paths = capsule_discovery_paths_for(&self.astrid_home, &self.workspace_root, principal); - let discovered = astrid_capsule::discovery::discover_manifests(Some(&paths)); + let paths = capsule_discovery_paths_for( + &self.astrid_home, + &self.workspace_root, + principal, + &self.workspace_layout, + ); + let discovered = astrid_capsule::discovery::discover_manifests_in_workspace( + Some(&paths), + None, + &self.workspace_layout, + ); match toposort_manifests(discovered) { Ok(sorted) => sorted, Err((e, original)) => { @@ -2173,6 +2236,7 @@ pub(crate) async fn test_kernel_with_home(home: astrid_core::dirs::AstridHome) - overlay_registry, vfs_root_handle: root_handle, workspace_root: home.root().to_path_buf(), + workspace_layout: WorkspaceLayout::default(), home_root: Some(principal_home.root().to_path_buf()), cli_socket_listener: None, singleton_lock: None, @@ -2859,15 +2923,24 @@ fn capsule_discovery_paths( home: &astrid_core::dirs::AstridHome, workspace_root: &Path, ) -> Vec { - capsule_discovery_paths_for(home, workspace_root, &PrincipalId::default()) + capsule_discovery_paths_for( + home, + workspace_root, + &PrincipalId::default(), + &WorkspaceLayout::default(), + ) } fn capsule_discovery_paths_for( home: &astrid_core::dirs::AstridHome, workspace_root: &Path, principal: &PrincipalId, + workspace_layout: &WorkspaceLayout, ) -> Vec { - let workspace = astrid_core::dirs::WorkspaceDir::from_path(workspace_root); + let workspace = astrid_core::dirs::WorkspaceDir::from_path_with_layout( + workspace_root, + workspace_layout.clone(), + ); vec![ home.principal_home(principal).capsules_dir(), workspace.capsules_dir(), @@ -3327,14 +3400,16 @@ fn mint_default_principal_keypair( async fn apply_identity_config( store: &Arc, workspace_root: &std::path::Path, + workspace_layout: &WorkspaceLayout, ) { - let config = match astrid_config::Config::load(Some(workspace_root)) { - Ok(resolved) => resolved.config, - Err(e) => { - tracing::debug!(error = %e, "No config loaded for identity links"); - return; - }, - }; + let config = + match astrid_config::Config::load_with_layout(Some(workspace_root), workspace_layout) { + Ok(resolved) => resolved.config, + Err(e) => { + tracing::debug!(error = %e, "No config loaded for identity links"); + return; + }, + }; for link_cfg in &config.identity.links { let result = apply_single_identity_link(store, link_cfg).await; @@ -3493,6 +3568,20 @@ mod tests { ); } + #[test] + fn capsule_discovery_paths_use_injected_workspace_layout() { + let (_d, home) = scratch_home(); + let workspace = tempfile::tempdir().unwrap(); + let layout = WorkspaceLayout::new(".alternate-runtime").unwrap(); + let paths = + capsule_discovery_paths_for(&home, workspace.path(), &PrincipalId::default(), &layout); + + assert_eq!( + paths[1], + workspace.path().join(".alternate-runtime").join("capsules") + ); + } + #[tokio::test(flavor = "multi_thread")] async fn unload_requests_cancel_before_waiting_for_exclusive_capsule() { let (_d, home) = scratch_home(); diff --git a/crates/astrid-kernel/src/socket.rs b/crates/astrid-kernel/src/socket.rs index 9f6df43be..08bcb829e 100644 --- a/crates/astrid-kernel/src/socket.rs +++ b/crates/astrid-kernel/src/socket.rs @@ -264,29 +264,56 @@ pub fn readiness_path() -> PathBuf { /// this as a fatal boot failure - without the sentinel, the CLI will never /// detect that the daemon is ready. pub fn write_readiness_file() -> Result<(), std::io::Error> { - use std::fs::OpenOptions; + let workspace_root = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + write_readiness_file_for_workspace( + &workspace_root, + &astrid_core::dirs::WorkspaceLayout::default(), + ) +} +/// Write readiness metadata for the selected project workspace. +/// +/// # Errors +/// Returns an error if the sentinel cannot be written. +pub fn write_readiness_file_for_workspace( + workspace_root: &std::path::Path, + workspace_layout: &astrid_core::dirs::WorkspaceLayout, +) -> Result<(), std::io::Error> { let path = readiness_path(); + let fingerprint = + astrid_core::dirs::workspace_selection_fingerprint(workspace_root, workspace_layout); + publish_readiness_metadata(&path, &format!("v1:{fingerprint}\n")) +} + +fn publish_readiness_metadata(path: &std::path::Path, metadata: &str) -> std::io::Result<()> { + use std::io::Write as _; - // Ensure the parent directory exists (defense-in-depth for contexts - // where bind_listener() has not run first). if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - - // Create the sentinel file with owner-only permissions set atomically - // via OpenOptions::mode() to avoid a TOCTOU window where the file exists - // with default permissions before chmod. - let mut opts = OpenOptions::new(); + let tmp = path.with_extension(format!("ready.tmp.{}", std::process::id())); + let mut opts = std::fs::OpenOptions::new(); opts.write(true).create(true).truncate(true); - #[cfg(unix)] { - use std::os::unix::fs::OpenOptionsExt; + use std::os::unix::fs::OpenOptionsExt as _; opts.mode(0o600); } - opts.open(&path)?; + let write_result = (|| -> std::io::Result<()> { + let mut file = opts.open(&tmp)?; + file.write_all(metadata.as_bytes())?; + file.flush()?; + file.sync_all() + })(); + if let Err(error) = write_result { + let _ = std::fs::remove_file(&tmp); + return Err(error); + } + if let Err(error) = std::fs::rename(&tmp, path) { + let _ = std::fs::remove_file(&tmp); + return Err(error); + } Ok(()) } @@ -411,6 +438,17 @@ mod tests { ); } + #[test] + fn readiness_metadata_is_published_atomically() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("system.ready"); + + publish_readiness_metadata(&path, "v1:selected\n").unwrap(); + + assert_eq!(std::fs::read_to_string(&path).unwrap(), "v1:selected\n"); + assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); + } + #[test] fn stale_socket_is_removed() { // Bind a listener, drop it (making the socket stale), then verify From e2d2e12d6ed5b85d6279a122e911f79276dcc137 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Tue, 14 Jul 2026 22:40:49 +0400 Subject: [PATCH 02/20] fix(core): satisfy workspace layout CI gates --- crates/astrid-cli/src/commands/init.rs | 18 ++-- crates/astrid-gateway/src/routes/mod.rs | 13 +-- crates/astrid-gateway/src/routes/models.rs | 1 + crates/astrid-gateway/src/routes/sessions.rs | 96 +++---------------- .../src/routes/sessions_layout.rs | 71 ++++++++++++++ 5 files changed, 100 insertions(+), 99 deletions(-) create mode 100644 crates/astrid-gateway/src/routes/sessions_layout.rs diff --git a/crates/astrid-cli/src/commands/init.rs b/crates/astrid-cli/src/commands/init.rs index 621215e7a..53d66d0e5 100644 --- a/crates/astrid-cli/src/commands/init.rs +++ b/crates/astrid-cli/src/commands/init.rs @@ -908,15 +908,15 @@ fn onboard_llm_providers( continue; } - let target_dir = match super::capsule::install::resolve_target_dir_for( - home, principal, &cap.name, false, - ) { - Ok(dir) => dir, - Err(e) => { - eprintln!(" Skipping {} onboarding: {e}", cap.name); - continue; - }, - }; + let target_dir = + match astrid_capsule_install::resolve_target_dir_for(home, principal, &cap.name, false) + { + Ok(dir) => dir, + Err(e) => { + eprintln!(" Skipping {} onboarding: {e}", cap.name); + continue; + }, + }; let manifest_path = target_dir.join("Capsule.toml"); let manifest = match astrid_capsule::discovery::load_manifest(&manifest_path) { Ok(m) => m, diff --git a/crates/astrid-gateway/src/routes/mod.rs b/crates/astrid-gateway/src/routes/mod.rs index 4244f7d7c..7ed8f2066 100644 --- a/crates/astrid-gateway/src/routes/mod.rs +++ b/crates/astrid-gateway/src/routes/mod.rs @@ -67,6 +67,7 @@ pub mod observability; pub mod principals; pub mod quotas; pub mod sessions; +mod sessions_layout; pub mod stream; pub mod system; @@ -321,24 +322,24 @@ fn build_authed_router(state: &Arc) -> Router> { // ── Conversation threads (proxied to capsule-session) ── .route( "/api/agent/sessions", - get(sessions::list_sessions_with_layout), + get(sessions_layout::list_sessions_with_layout), ) // `search` is a static segment and is registered before the `:id` // routes; axum prefers the static match, so `/sessions/search` never // collides with `/sessions/:id`. .route( "/api/agent/sessions/search", - get(sessions::search_sessions_with_layout), + get(sessions_layout::search_sessions_with_layout), ) .route( "/api/agent/sessions/{id}", - get(sessions::get_session_with_layout) - .patch(sessions::update_session_with_layout) - .delete(sessions::delete_session_with_layout), + get(sessions_layout::get_session_with_layout) + .patch(sessions_layout::update_session_with_layout) + .delete(sessions_layout::delete_session_with_layout), ) .route( "/api/agent/sessions/{id}/messages", - get(sessions::get_session_messages_with_layout), + get(sessions_layout::get_session_messages_with_layout), ) // ── Agent elicitation reply ── .route( diff --git a/crates/astrid-gateway/src/routes/models.rs b/crates/astrid-gateway/src/routes/models.rs index e01ad2c68..328355bba 100644 --- a/crates/astrid-gateway/src/routes/models.rs +++ b/crates/astrid-gateway/src/routes/models.rs @@ -540,6 +540,7 @@ mod tests { use std::sync::atomic::{AtomicBool, Ordering}; use crate::error::GatewayError; + use crate::routes::WorkspaceContext; use crate::routes::capsule_sources::capsule_source_id_v0; use crate::state::{GatewayState, SigningMaterial}; use astrid_core::PrincipalId; diff --git a/crates/astrid-gateway/src/routes/sessions.rs b/crates/astrid-gateway/src/routes/sessions.rs index 8c6bf1ec6..23b39089e 100644 --- a/crates/astrid-gateway/src/routes/sessions.rs +++ b/crates/astrid-gateway/src/routes/sessions.rs @@ -1,31 +1,14 @@ //! Principal-scoped session HTTP routes backed by the session capsule. //! -//! Every route proxies to the `capsule-session` capsule over the -//! in-process event bus. The gateway: -//! -//! 1. Generates a fresh `correlation_id` (UUID v4). -//! 2. Subscribes to `session.v1.response..` before -//! publishing so a fast reply cannot race the subscription. -//! 3. Publishes the request, principal-stamped, on the verb's request -//! topic. -//! 4. Awaits one reply on a route scoped to the caller principal, then -//! verifies the responder source and body correlation. +//! Routes proxy to `capsule-session` over the in-process event bus. Each request +//! gets a fresh correlation ID; the gateway subscribes before publishing, +//! stamps the verified principal, and validates the reply source and body. //! //! ## Trust boundary //! -//! The principal stamp is the caller authority. The kernel scopes -//! capsule-session's KV reads to the stamped principal's namespace, so -//! a caller only ever sees their own threads. The response authority is -//! the kernel-stamped capsule `source_id`: a different capsule may -//! declare the same response topic, but its reply is ignored unless it -//! came from `astrid-capsule-session`. We stamp `caller.principal` (and -//! `caller.device_key_id` when the bearer is device-scoped, exactly as -//! `bus_admin.rs` does) on every outbound request. The path `{id}` and -//! every query param are payload data — they NEVER substitute for the -//! principal and never reach a topic segment (the response topic is -//! keyed on the gateway-generated `correlation_id`, not on caller -//! input), so a malicious `id` cannot cross into another principal's -//! namespace or hijack another request's reply. +//! The kernel scopes capsule KV reads to the verified principal. Replies must +//! carry the kernel-stamped session-capsule source ID. Path and query values are +//! payload only; response topics use gateway-generated correlation IDs. use std::sync::Arc; use std::time::Duration; @@ -33,7 +16,6 @@ use std::time::Duration; use astrid_core::PrincipalId; use astrid_events::ipc::{IpcMessage, IpcPayload, MessageOrigin, Topic}; use astrid_events::{AstridEvent, EventBus, EventMetadata}; -use axum::Extension; use axum::Json; use axum::extract::{Path, Query, State}; use axum::http::Request; @@ -338,16 +320,7 @@ pub async fn list_sessions( list_sessions_inner(state, &WorkspaceContext::default(), query, req).await } -pub(crate) async fn list_sessions_with_layout( - State(state): State>, - Extension(workspace): Extension, - Query(query): Query, - req: Request, -) -> GatewayResult> { - list_sessions_inner(state, &workspace, query, req).await -} - -async fn list_sessions_inner( +pub(super) async fn list_sessions_inner( state: Arc, workspace: &WorkspaceContext, query: SessionListQuery, @@ -423,16 +396,7 @@ pub async fn get_session_messages( get_session_messages_inner(state, &WorkspaceContext::default(), id, req).await } -pub(crate) async fn get_session_messages_with_layout( - State(state): State>, - Extension(workspace): Extension, - Path(id): Path, - req: Request, -) -> GatewayResult> { - get_session_messages_inner(state, &workspace, id, req).await -} - -async fn get_session_messages_inner( +pub(super) async fn get_session_messages_inner( state: Arc, workspace: &WorkspaceContext, id: String, @@ -501,16 +465,7 @@ pub async fn get_session( get_session_inner(state, &WorkspaceContext::default(), id, req).await } -pub(crate) async fn get_session_with_layout( - State(state): State>, - Extension(workspace): Extension, - Path(id): Path, - req: Request, -) -> GatewayResult> { - get_session_inner(state, &workspace, id, req).await -} - -async fn get_session_inner( +pub(super) async fn get_session_inner( state: Arc, workspace: &WorkspaceContext, id: String, @@ -584,16 +539,7 @@ pub async fn update_session( update_session_inner(state, &WorkspaceContext::default(), id, req).await } -pub(crate) async fn update_session_with_layout( - State(state): State>, - Extension(workspace): Extension, - Path(id): Path, - req: Request, -) -> GatewayResult> { - update_session_inner(state, &workspace, id, req).await -} - -async fn update_session_inner( +pub(super) async fn update_session_inner( state: Arc, workspace: &WorkspaceContext, id: String, @@ -666,16 +612,7 @@ pub async fn delete_session( delete_session_inner(state, &WorkspaceContext::default(), id, req).await } -pub(crate) async fn delete_session_with_layout( - State(state): State>, - Extension(workspace): Extension, - Path(id): Path, - req: Request, -) -> GatewayResult> { - delete_session_inner(state, &workspace, id, req).await -} - -async fn delete_session_inner( +pub(super) async fn delete_session_inner( state: Arc, workspace: &WorkspaceContext, id: String, @@ -743,16 +680,7 @@ pub async fn search_sessions( search_sessions_inner(state, &WorkspaceContext::default(), query, req).await } -pub(crate) async fn search_sessions_with_layout( - State(state): State>, - Extension(workspace): Extension, - Query(query): Query, - req: Request, -) -> GatewayResult> { - search_sessions_inner(state, &workspace, query, req).await -} - -async fn search_sessions_inner( +pub(super) async fn search_sessions_inner( state: Arc, workspace: &WorkspaceContext, query: SearchQuery, diff --git a/crates/astrid-gateway/src/routes/sessions_layout.rs b/crates/astrid-gateway/src/routes/sessions_layout.rs new file mode 100644 index 000000000..4e6d7175d --- /dev/null +++ b/crates/astrid-gateway/src/routes/sessions_layout.rs @@ -0,0 +1,71 @@ +//! Axum adapters for session routes using the daemon-selected workspace. + +use std::sync::Arc; + +use axum::extract::{Path, Query, State}; +use axum::http::Request; +use axum::{Extension, Json}; + +use crate::error::GatewayResult; +use crate::state::GatewayState; + +use super::WorkspaceContext; +use super::sessions::{ + DeleteResponse, SearchQuery, SearchResponse, SessionListQuery, SessionListResponse, + SessionSummary, TranscriptResponse, delete_session_inner, get_session_inner, + get_session_messages_inner, list_sessions_inner, search_sessions_inner, update_session_inner, +}; + +pub(super) async fn list_sessions_with_layout( + State(state): State>, + Extension(workspace): Extension, + Query(query): Query, + req: Request, +) -> GatewayResult> { + list_sessions_inner(state, &workspace, query, req).await +} + +pub(super) async fn get_session_messages_with_layout( + State(state): State>, + Extension(workspace): Extension, + Path(id): Path, + req: Request, +) -> GatewayResult> { + get_session_messages_inner(state, &workspace, id, req).await +} + +pub(super) async fn get_session_with_layout( + State(state): State>, + Extension(workspace): Extension, + Path(id): Path, + req: Request, +) -> GatewayResult> { + get_session_inner(state, &workspace, id, req).await +} + +pub(super) async fn update_session_with_layout( + State(state): State>, + Extension(workspace): Extension, + Path(id): Path, + req: Request, +) -> GatewayResult> { + update_session_inner(state, &workspace, id, req).await +} + +pub(super) async fn delete_session_with_layout( + State(state): State>, + Extension(workspace): Extension, + Path(id): Path, + req: Request, +) -> GatewayResult> { + delete_session_inner(state, &workspace, id, req).await +} + +pub(super) async fn search_sessions_with_layout( + State(state): State>, + Extension(workspace): Extension, + Query(query): Query, + req: Request, +) -> GatewayResult> { + search_sessions_inner(state, &workspace, query, req).await +} From ddf73ae8684acf117f1806c86374e3ed4510959f Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Tue, 14 Jul 2026 22:55:16 +0400 Subject: [PATCH 03/20] fix(core): close workspace layout CI findings --- crates/astrid-config/src/loader.rs | 6 +++--- crates/astrid-core/src/dirs.rs | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/astrid-config/src/loader.rs b/crates/astrid-config/src/loader.rs index 04e0e7f32..200f62cfd 100644 --- a/crates/astrid-config/src/loader.rs +++ b/crates/astrid-config/src/loader.rs @@ -366,12 +366,12 @@ mod tests { std::fs::create_dir_all(&alternate_dir).unwrap(); std::fs::write( default_dir.join("config.toml"), - "[model]\nprovider = \"default-root\"\n", + "[model]\nprovider = \"openai\"\n", ) .unwrap(); std::fs::write( alternate_dir.join("config.toml"), - "[model]\nprovider = \"alternate-root\"\n", + "[model]\nprovider = \"claude\"\n", ) .unwrap(); @@ -379,7 +379,7 @@ mod tests { let resolved = load_with_layout(Some(workspace.path()), Some(home.path()), &layout).unwrap(); - assert_eq!(resolved.config.model.provider, "alternate-root"); + assert_eq!(resolved.config.model.provider, "claude"); assert!( resolved .loaded_files diff --git a/crates/astrid-core/src/dirs.rs b/crates/astrid-core/src/dirs.rs index a301d785c..d333a2e05 100644 --- a/crates/astrid-core/src/dirs.rs +++ b/crates/astrid-core/src/dirs.rs @@ -180,8 +180,7 @@ pub fn workspace_selection_fingerprint( project_root.to_path_buf() } else { std::env::current_dir() - .map(|cwd| cwd.join(project_root)) - .unwrap_or_else(|_| project_root.to_path_buf()) + .map_or_else(|_| project_root.to_path_buf(), |cwd| cwd.join(project_root)) } }); let mut hasher = Sha256::new(); From 0eef5326c7e509079b6ee7cd7508bc192c592c77 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Tue, 14 Jul 2026 23:03:01 +0400 Subject: [PATCH 04/20] fix(core): close remaining workspace layout lints --- .../src/manifest_check.rs | 5 +- crates/astrid-capsule-install/src/paths.rs | 46 +++++++++---------- 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/crates/astrid-capsule-install/src/manifest_check.rs b/crates/astrid-capsule-install/src/manifest_check.rs index d92303b4c..4144b6ea3 100644 --- a/crates/astrid-capsule-install/src/manifest_check.rs +++ b/crates/astrid-capsule-install/src/manifest_check.rs @@ -36,9 +36,8 @@ pub fn validate_imports_with_layout( manifest: &CapsuleManifest, workspace_layout: &WorkspaceLayout, ) -> Vec { - let home = match astrid_core::dirs::AstridHome::resolve() { - Ok(home) => home, - Err(_) => return Vec::new(), + let Ok(home) = astrid_core::dirs::AstridHome::resolve() else { + return Vec::new(); }; let workspace_root = std::env::current_dir().ok(); validate_imports_in_workspace( diff --git a/crates/astrid-capsule-install/src/paths.rs b/crates/astrid-capsule-install/src/paths.rs index eaf922492..e4ebcdfd5 100644 --- a/crates/astrid-capsule-install/src/paths.rs +++ b/crates/astrid-capsule-install/src/paths.rs @@ -89,29 +89,6 @@ pub fn resolve_target_dir_for_in_workspace( } } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn workspace_target_uses_injected_layout() { - let layout = WorkspaceLayout::new(".alternate-runtime").unwrap(); - let root = Path::new("/workspace"); - assert_eq!( - resolve_target_dir_for_in_workspace( - &AstridHome::from_path("/home/runtime"), - &install_principal(), - "example", - true, - Some(root), - &layout, - ) - .unwrap(), - PathBuf::from("/workspace/.alternate-runtime/capsules/example") - ); - } -} - /// Resolve the path to a capsule's env config file. /// /// Returns `home/{principal}/.config/env/{capsule}.env.json`. @@ -152,3 +129,26 @@ pub fn restore_env_from_backup_for( let _ = std::fs::copy(&old_env, env_path); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workspace_target_uses_injected_layout() { + let layout = WorkspaceLayout::new(".alternate-runtime").unwrap(); + let root = Path::new("/workspace"); + assert_eq!( + resolve_target_dir_for_in_workspace( + &AstridHome::from_path("/home/runtime"), + &install_principal(), + "example", + true, + Some(root), + &layout, + ) + .unwrap(), + PathBuf::from("/workspace/.alternate-runtime/capsules/example") + ); + } +} From 8aec624684c49f48d1088186bde912a71c0c5e60 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Tue, 14 Jul 2026 23:21:12 +0400 Subject: [PATCH 05/20] fix(core): satisfy remaining workspace layout lint gates --- .../src/routes/capsule_sources.rs | 7 ++- crates/astrid-gateway/src/routes/mod.rs | 44 ++++++++++--------- crates/astrid-kernel/src/lib.rs | 15 +++++++ 3 files changed, 41 insertions(+), 25 deletions(-) diff --git a/crates/astrid-gateway/src/routes/capsule_sources.rs b/crates/astrid-gateway/src/routes/capsule_sources.rs index c6e1ad6ca..027bd7e43 100644 --- a/crates/astrid-gateway/src/routes/capsule_sources.rs +++ b/crates/astrid-gateway/src/routes/capsule_sources.rs @@ -41,13 +41,12 @@ pub(super) fn trusted_capsule_source_ids( // principal may have its own installed version); it no longer contributes to // the derived source id, which is content-addressed and shared across // principals (issue #1069). - let mut dirs = Vec::new(); - dirs.push(home.principal_home(caller).capsules_dir().join(capsule_id)); - dirs.push( + let dirs = vec![ + home.principal_home(caller).capsules_dir().join(capsule_id), workspace_layout .capsules_dir(workspace_root) .join(capsule_id), - ); + ]; trusted_capsule_source_ids_from_dirs(capsule_id, dirs) } diff --git a/crates/astrid-gateway/src/routes/mod.rs b/crates/astrid-gateway/src/routes/mod.rs index 7ed8f2066..da007e731 100644 --- a/crates/astrid-gateway/src/routes/mod.rs +++ b/crates/astrid-gateway/src/routes/mod.rs @@ -320,27 +320,7 @@ fn build_authed_router(state: &Arc) -> Router> { // ── Per-principal live conversation feed (SSE, #973) ── .route("/api/agent/stream", get(stream::get_stream)) // ── Conversation threads (proxied to capsule-session) ── - .route( - "/api/agent/sessions", - get(sessions_layout::list_sessions_with_layout), - ) - // `search` is a static segment and is registered before the `:id` - // routes; axum prefers the static match, so `/sessions/search` never - // collides with `/sessions/:id`. - .route( - "/api/agent/sessions/search", - get(sessions_layout::search_sessions_with_layout), - ) - .route( - "/api/agent/sessions/{id}", - get(sessions_layout::get_session_with_layout) - .patch(sessions_layout::update_session_with_layout) - .delete(sessions_layout::delete_session_with_layout), - ) - .route( - "/api/agent/sessions/{id}/messages", - get(sessions_layout::get_session_messages_with_layout), - ) + .merge(build_session_router()) // ── Agent elicitation reply ── .route( "/api/agent/elicit-response", @@ -373,6 +353,28 @@ fn build_authed_router(state: &Arc) -> Router> { )) } +fn build_session_router() -> Router> { + Router::new() + .route( + "/api/agent/sessions", + get(sessions_layout::list_sessions_with_layout), + ) + .route( + "/api/agent/sessions/search", + get(sessions_layout::search_sessions_with_layout), + ) + .route( + "/api/agent/sessions/{id}", + get(sessions_layout::get_session_with_layout) + .patch(sessions_layout::update_session_with_layout) + .delete(sessions_layout::delete_session_with_layout), + ) + .route( + "/api/agent/sessions/{id}/messages", + get(sessions_layout::get_session_messages_with_layout), + ) +} + /// Apply the four static security headers every gateway response /// carries. `if_not_present` means a handler that intentionally /// sets one of these wins; this layer only fills in the defaults. diff --git a/crates/astrid-kernel/src/lib.rs b/crates/astrid-kernel/src/lib.rs index eb2b3c93f..de0995059 100644 --- a/crates/astrid-kernel/src/lib.rs +++ b/crates/astrid-kernel/src/lib.rs @@ -396,6 +396,11 @@ impl Kernel { } /// Boot a kernel with an explicit per-project runtime layout. + /// + /// # Errors + /// + /// Returns an error if the Astrid home or native resources cannot be + /// acquired, or if portable kernel wiring fails. #[cfg(unix)] pub async fn new_with_workspace_layout( session_id: SessionId, @@ -537,6 +542,16 @@ impl Kernel { } /// Construct a kernel from injected resources and workspace layout. + /// + /// # Panics + /// + /// Panics on native targets when called from a single-threaded tokio + /// runtime because the capsule engine requires `block_in_place`. + /// + /// # Errors + /// + /// Returns an error if VFS mounts, the capability store, group + /// configuration, or CLI root bootstrap cannot be initialized. #[expect( clippy::too_many_lines, reason = "boot sequence: sequential setup that does not benefit from splitting" From 7fd8b717f5e6b2ef27b7cfdf7923482cb9b9b3a3 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Tue, 14 Jul 2026 23:31:30 +0400 Subject: [PATCH 06/20] fix(cli): satisfy workspace metadata lint --- crates/astrid-cli/src/commands/daemon.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/astrid-cli/src/commands/daemon.rs b/crates/astrid-cli/src/commands/daemon.rs index 00896adb5..f91e11b8b 100644 --- a/crates/astrid-cli/src/commands/daemon.rs +++ b/crates/astrid-cli/src/commands/daemon.rs @@ -202,8 +202,7 @@ pub(crate) async fn ensure_daemon_workspace_matches(workspace_root: Option<&Path } anyhow::bail!( - "daemon workspace metadata was not available within {} seconds; run `astrid restart`", - DAEMON_READY_TIMEOUT_SECS + "daemon workspace metadata was not available within {DAEMON_READY_TIMEOUT_SECS} seconds; run `astrid restart`" ) } From 2792d20794a56451b5b7a04feaa4f3df002b2678 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 00:00:54 +0400 Subject: [PATCH 07/20] fix(kernel): secure readiness state directory --- crates/astrid-kernel/src/socket.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/astrid-kernel/src/socket.rs b/crates/astrid-kernel/src/socket.rs index 08bcb829e..7a6bac474 100644 --- a/crates/astrid-kernel/src/socket.rs +++ b/crates/astrid-kernel/src/socket.rs @@ -290,6 +290,11 @@ fn publish_readiness_metadata(path: &std::path::Path, metadata: &str) -> std::io if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?; + } } let tmp = path.with_extension(format!("ready.tmp.{}", std::process::id())); let mut opts = std::fs::OpenOptions::new(); @@ -441,12 +446,19 @@ mod tests { #[test] fn readiness_metadata_is_published_atomically() { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("system.ready"); + let run_dir = dir.path().join("run"); + let path = run_dir.join("system.ready"); publish_readiness_metadata(&path, "v1:selected\n").unwrap(); assert_eq!(std::fs::read_to_string(&path).unwrap(), "v1:selected\n"); - assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1); + assert_eq!(std::fs::read_dir(&run_dir).unwrap().count(), 1); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mode = std::fs::metadata(&run_dir).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700); + } } #[test] From ba16bd4f11727205e1d35e1f12c734ed979ef8c5 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 00:43:41 +0400 Subject: [PATCH 08/20] fix(cli): validate the selected workspace once --- crates/astrid-cli/src/bootstrap.rs | 54 ++++++++++--------- .../astrid-cli/src/commands/capsule_verb.rs | 4 +- crates/astrid-cli/src/commands/daemon.rs | 39 +++++++++++--- crates/astrid-cli/src/commands/headless.rs | 4 +- crates/astrid-cli/src/commands/mcp/mod.rs | 2 +- .../src/commands/mcp/session_guard.rs | 2 +- crates/astrid-cli/src/commands/mcp/watch.rs | 3 +- crates/astrid-cli/src/socket_client.rs | 3 +- 8 files changed, 69 insertions(+), 42 deletions(-) diff --git a/crates/astrid-cli/src/bootstrap.rs b/crates/astrid-cli/src/bootstrap.rs index 47121b9ca..f4e7e39ae 100644 --- a/crates/astrid-cli/src/bootstrap.rs +++ b/crates/astrid-cli/src/bootstrap.rs @@ -192,32 +192,34 @@ pub(crate) async fn run_or_connect( } } - let mut client = - match socket_client::connect_for_workspace(session_id.clone(), crate::principal::current()) - .await - { - Ok(c) => { - commands::daemon::ensure_daemon_workspace_matches(workspace.as_deref()).await?; - drop(daemon_child); - c - }, - Err(e) => { - if let Some(mut child) = daemon_child { - let _ = child.kill(); - let _ = child.wait(); - } - let log_hint = astrid_core::dirs::AstridHome::resolve().map_or_else( - |_| "Failed to connect to daemon".to_string(), - |h| { - format!( - "Failed to connect to daemon. Check logs: {}", - h.log_dir().display() - ) - }, - ); - return Err(e.context(log_hint)); - }, - }; + let mut client = match socket_client::connect_for_workspace( + session_id.clone(), + crate::principal::current(), + workspace.as_deref(), + ) + .await + { + Ok(c) => { + drop(daemon_child); + c + }, + Err(e) => { + if let Some(mut child) = daemon_child { + let _ = child.kill(); + let _ = child.wait(); + } + let log_hint = astrid_core::dirs::AstridHome::resolve().map_or_else( + |_| "Failed to connect to daemon".to_string(), + |h| { + format!( + "Failed to connect to daemon. Check logs: {}", + h.log_dir().display() + ) + }, + ); + return Err(e.context(log_hint)); + }, + }; let workspace_root = std::env::current_dir().ok(); let model_name = astrid_config::Config::load_with_layout( diff --git a/crates/astrid-cli/src/commands/capsule_verb.rs b/crates/astrid-cli/src/commands/capsule_verb.rs index a4f7d07ee..9db1d5b0a 100644 --- a/crates/astrid-cli/src/commands/capsule_verb.rs +++ b/crates/astrid-cli/src/commands/capsule_verb.rs @@ -171,7 +171,7 @@ async fn resolve_commands() -> Result> { // (admin) principal — letting a non-admin enumerate capsule verbs under // admin context, an RBAC bypass. let caller = crate::principal::current(); - let mut client = crate::socket_client::connect_for_workspace(session, caller.clone()) + let mut client = crate::socket_client::connect_for_workspace(session, caller.clone(), None) .await .map_err(|e| anyhow::anyhow!("Failed to connect to daemon: {e}"))?; @@ -210,7 +210,7 @@ async fn execute(provider: &str, verb: &str, args: &[String]) -> Result c, Err(e) => { eprintln!( diff --git a/crates/astrid-cli/src/commands/daemon.rs b/crates/astrid-cli/src/commands/daemon.rs index f91e11b8b..247a842a6 100644 --- a/crates/astrid-cli/src/commands/daemon.rs +++ b/crates/astrid-cli/src/commands/daemon.rs @@ -179,14 +179,7 @@ async fn ensure_daemon_inner(label: &str, announce: bool) -> Result<()> { } pub(crate) async fn ensure_daemon_workspace_matches(workspace_root: Option<&Path>) -> Result<()> { - let root = workspace_root.map_or_else( - || std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - Path::to_path_buf, - ); - let expected = astrid_core::dirs::workspace_selection_fingerprint( - &root, - crate::workspace_layout::current(), - ); + let expected = expected_workspace_fingerprint(workspace_root); let ready_path = socket_client::readiness_path(); for _ in 0..DAEMON_READY_ATTEMPTS { @@ -206,6 +199,14 @@ pub(crate) async fn ensure_daemon_workspace_matches(workspace_root: Option<&Path ) } +fn expected_workspace_fingerprint(workspace_root: Option<&Path>) -> String { + let root = workspace_root.map_or_else( + || std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + Path::to_path_buf, + ); + astrid_core::dirs::workspace_selection_fingerprint(&root, crate::workspace_layout::current()) +} + fn validate_daemon_workspace_metadata(metadata: &str, expected: &str) -> Result<()> { let Some(actual) = metadata.trim().strip_prefix("v1:") else { anyhow::bail!( @@ -681,6 +682,28 @@ mod tests { validate_daemon_workspace_metadata(&format!("v1:{expected}\n"), &expected).unwrap(); } + #[test] + fn explicit_workspace_selection_wins_over_current_directory() { + let current = std::env::current_dir().expect("current directory"); + let explicit = tempfile::tempdir().expect("explicit workspace"); + assert_ne!(explicit.path(), current); + + assert_eq!( + expected_workspace_fingerprint(Some(explicit.path())), + astrid_core::dirs::workspace_selection_fingerprint( + explicit.path(), + crate::workspace_layout::current(), + ) + ); + assert_eq!( + expected_workspace_fingerprint(None), + astrid_core::dirs::workspace_selection_fingerprint( + ¤t, + crate::workspace_layout::current(), + ) + ); + } + /// REGRESSION (#1120): `astrid stop` must remove the socket/PID files ONLY /// when the daemon is confirmed gone. Before the fix, stop reported success /// and deleted the PID file on the shutdown ACK alone; a daemon that then diff --git a/crates/astrid-cli/src/commands/headless.rs b/crates/astrid-cli/src/commands/headless.rs index a75b8630f..baf6a805e 100644 --- a/crates/astrid-cli/src/commands/headless.rs +++ b/crates/astrid-cli/src/commands/headless.rs @@ -51,7 +51,7 @@ pub(crate) async fn run_snapshot_tui( }; let mut client = - socket_client::connect_for_workspace(session_id.clone(), crate::principal::current()) + socket_client::connect_for_workspace(session_id.clone(), crate::principal::current(), None) .await .context("Failed to connect to daemon")?; @@ -114,7 +114,7 @@ pub(crate) async fn run_headless( SessionId::from_uuid(id) }; let mut client = - socket_client::connect_for_workspace(session_id.clone(), crate::principal::current()) + socket_client::connect_for_workspace(session_id.clone(), crate::principal::current(), None) .await .context("Failed to connect to daemon")?; diff --git a/crates/astrid-cli/src/commands/mcp/mod.rs b/crates/astrid-cli/src/commands/mcp/mod.rs index e91a0e814..f8d2277e2 100644 --- a/crates/astrid-cli/src/commands/mcp/mod.rs +++ b/crates/astrid-cli/src/commands/mcp/mod.rs @@ -120,7 +120,7 @@ pub(crate) async fn serve(principal: Option<&str>) -> Result { // not a chat session; the kernel attributes work via the per-message // `principal`, not the session. let session = astrid_core::SessionId::from_uuid(Uuid::new_v4()); - let client = crate::socket_client::connect_for_workspace(session, caller.clone()) + let client = crate::socket_client::connect_for_workspace(session, caller.clone(), None) .await .context("Failed to connect to the Astrid daemon socket")?; diff --git a/crates/astrid-cli/src/commands/mcp/session_guard.rs b/crates/astrid-cli/src/commands/mcp/session_guard.rs index 005bb3716..72f8fd44d 100644 --- a/crates/astrid-cli/src/commands/mcp/session_guard.rs +++ b/crates/astrid-cli/src/commands/mcp/session_guard.rs @@ -37,7 +37,7 @@ async fn hold_guard_uplink( daemon::ensure_daemon_quiet("mcp-session-guard").await?; let session = astrid_core::SessionId::from_uuid(Uuid::new_v4()); - let c = crate::socket_client::connect_for_workspace(session, principal.clone()) + let c = crate::socket_client::connect_for_workspace(session, principal.clone(), None) .await .context("failed to connect guard uplink to daemon")?; diff --git a/crates/astrid-cli/src/commands/mcp/watch.rs b/crates/astrid-cli/src/commands/mcp/watch.rs index fa7cd0a2d..a9cc448fb 100644 --- a/crates/astrid-cli/src/commands/mcp/watch.rs +++ b/crates/astrid-cli/src/commands/mcp/watch.rs @@ -84,7 +84,8 @@ pub(super) async fn run(peer: Peer, principal: String) { }, }; let session = astrid_core::SessionId::from_uuid(Uuid::new_v4()); - let mut watch_client = match crate::socket_client::connect_for_workspace(session, caller).await + let mut watch_client = match crate::socket_client::connect_for_workspace(session, caller, None) + .await { Ok(c) => c, Err(e) => { diff --git a/crates/astrid-cli/src/socket_client.rs b/crates/astrid-cli/src/socket_client.rs index 340727d80..9f7396ada 100644 --- a/crates/astrid-cli/src/socket_client.rs +++ b/crates/astrid-cli/src/socket_client.rs @@ -14,9 +14,10 @@ use anyhow::Result; pub(crate) async fn connect_for_workspace( session: astrid_core::SessionId, principal: astrid_core::PrincipalId, + workspace_root: Option<&std::path::Path>, ) -> Result { let client = SocketClient::connect(session, principal).await?; - crate::commands::daemon::ensure_daemon_workspace_matches(None).await?; + crate::commands::daemon::ensure_daemon_workspace_matches(workspace_root).await?; Ok(client) } From a694e0bf68ab616215bba7b30402ee3bfc3b2dd1 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 06:34:53 +0400 Subject: [PATCH 09/20] fix(core): build workspace fingerprinting with BLAKE3 --- crates/astrid-core/src/dirs.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/astrid-core/src/dirs.rs b/crates/astrid-core/src/dirs.rs index d333a2e05..3686bf77f 100644 --- a/crates/astrid-core/src/dirs.rs +++ b/crates/astrid-core/src/dirs.rs @@ -60,7 +60,7 @@ use std::io; use std::path::{Component, Path, PathBuf}; use std::str::FromStr; -use sha2::{Digest, Sha256}; +use blake3::Hasher; use uuid::Uuid; use crate::principal::PrincipalId; @@ -183,15 +183,15 @@ pub fn workspace_selection_fingerprint( .map_or_else(|_| project_root.to_path_buf(), |cwd| cwd.join(project_root)) } }); - let mut hasher = Sha256::new(); + let mut hasher = Hasher::new(); hasher.update(b"astrid-workspace-selection-v1\0"); hash_path(&mut hasher, &root); hasher.update(b"\0"); hasher.update(workspace_layout.state_dir_name().as_bytes()); - hex::encode(hasher.finalize()) + hex::encode(hasher.finalize().as_bytes()) } -fn hash_path(hasher: &mut Sha256, path: &Path) { +fn hash_path(hasher: &mut Hasher, path: &Path) { #[cfg(unix)] { use std::os::unix::ffi::OsStrExt as _; From ea4ce1198b15e16b870a0c60504b6a6f2496fe8b Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 06:36:15 +0400 Subject: [PATCH 10/20] fix(core): preserve Windows path hashing --- crates/astrid-core/src/dirs.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/astrid-core/src/dirs.rs b/crates/astrid-core/src/dirs.rs index 3686bf77f..3abc72e33 100644 --- a/crates/astrid-core/src/dirs.rs +++ b/crates/astrid-core/src/dirs.rs @@ -201,7 +201,7 @@ fn hash_path(hasher: &mut Hasher, path: &Path) { { use std::os::windows::ffi::OsStrExt as _; for unit in path.as_os_str().encode_wide() { - hasher.update(unit.to_le_bytes()); + hasher.update(&unit.to_le_bytes()); } } #[cfg(not(any(unix, windows)))] From c5d5a26755381f88d2a76b86002b714630601c18 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 11:01:02 +0400 Subject: [PATCH 11/20] Gate CLI daemon clients by workspace --- CHANGELOG.md | 5 +- crates/astrid-cli/src/admin_client.rs | 16 +- crates/astrid-cli/src/bootstrap.rs | 2 +- .../src/commands/capsule/live_load.rs | 79 ++++++-- crates/astrid-cli/src/commands/daemon.rs | 7 +- crates/astrid-cli/src/commands/doctor.rs | 5 +- crates/astrid-cli/src/commands/invite.rs | 4 +- crates/astrid-cli/src/commands/ps.rs | 13 +- crates/astrid-cli/src/commands/who.rs | 13 +- crates/astrid-cli/src/socket_client.rs | 185 +++++++++++++++++- 10 files changed, 285 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 337877bdc..b001d7346 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,8 +91,9 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. relative directory name through `--workspace-state-dir` or `ASTRID_WORKSPACE_STATE_DIR`. Config, capsule installation and discovery, kernel boot, gateway source checks, hooks, and WIT garbage collection share - the selected layout and never scan both project roots. CLI uplinks reject a - daemon booted for a different project or layout. + the selected layout and never scan both project roots. CLI uplinks and + project-sensitive management reads reject a daemon booted for a different + project or layout; daemon stop remains available as a recovery operation. - **Runtime E2E now stages the pinned Unicity AOS monorepo.** The workflow preserves the AOS Cargo workspace outside the core checkout and supplies diff --git a/crates/astrid-cli/src/admin_client.rs b/crates/astrid-cli/src/admin_client.rs index 71703dc3d..7eb6ed78c 100644 --- a/crates/astrid-cli/src/admin_client.rs +++ b/crates/astrid-cli/src/admin_client.rs @@ -23,5 +23,19 @@ use anyhow::Result; /// Returns an error if the socket file is missing (no daemon), /// connection fails, or the handshake is rejected. pub(crate) async fn connect_as_active_agent() -> Result { - AdminClient::connect(crate::principal::current()).await + connect_for_workspace_as(crate::principal::current()).await +} + +/// Connect an admin client as an explicit caller after verifying the selected +/// workspace on both sides of the daemon handshake. +/// +/// Invite redemption uses the default principal because the invite token is +/// the authentication credential; the workspace boundary still applies. +pub(crate) async fn connect_for_workspace_as( + caller: astrid_core::PrincipalId, +) -> Result { + Ok( + crate::socket_client::connect_workspace_client(None, || AdminClient::connect(caller)) + .await?, + ) } diff --git a/crates/astrid-cli/src/bootstrap.rs b/crates/astrid-cli/src/bootstrap.rs index f4e7e39ae..055e290c3 100644 --- a/crates/astrid-cli/src/bootstrap.rs +++ b/crates/astrid-cli/src/bootstrap.rs @@ -217,7 +217,7 @@ pub(crate) async fn run_or_connect( ) }, ); - return Err(e.context(log_hint)); + return Err(anyhow::Error::new(e).context(log_hint)); }, }; diff --git a/crates/astrid-cli/src/commands/capsule/live_load.rs b/crates/astrid-cli/src/commands/capsule/live_load.rs index 3cad10548..6bbe18cb4 100644 --- a/crates/astrid-cli/src/commands/capsule/live_load.rs +++ b/crates/astrid-cli/src/commands/capsule/live_load.rs @@ -28,9 +28,10 @@ pub(crate) async fn nudge_daemon_reload(capsule_ids: &[String]) { if capsule_ids.is_empty() { return; } - // No socket file => no daemon to nudge. Stay silent: the standalone install - // already reported success, and the capsule loads on the next daemon start. - if !crate::socket_client::proxy_socket_path().exists() { + // A stale socket is still an offline install. Probe reachability without an + // authenticated handshake before the workspace checks so missing readiness + // metadata cannot turn an unreachable daemon into a 60-second wait. + if !daemon_socket_reachable().await { return; } // One fresh session UUID, used for BOTH the connection's SessionId and each @@ -38,18 +39,17 @@ pub(crate) async fn nudge_daemon_reload(capsule_ids: &[String]) { // session — never the reserved nil UUID, which is SYSTEM_SESSION_UUID. let session_uuid = uuid::Uuid::new_v4(); let session = astrid_core::SessionId::from_uuid(session_uuid); - let Ok(mut client) = - crate::socket_client::SocketClient::connect(session, crate::principal::current()).await - else { - // Socket present but unreachable (e.g. a hung/stale daemon). Leave the - // install standalone rather than failing it. - return; + let mut client = match classify_live_client( + crate::socket_client::connect_for_workspace(session, crate::principal::current(), None) + .await, + ) { + Ok(Some(client)) => client, + Ok(None) => return, + Err(error) => { + eprintln!("Note: installed capsules to disk, but skipped live reload because {error}."); + return; + }, }; - // Validate after connect so a concurrent daemon restart cannot retarget us. - if let Err(error) = crate::commands::daemon::ensure_daemon_workspace_matches(None).await { - eprintln!("Note: installed capsules to disk, but skipped live reload because {error}."); - return; - } for id in capsule_ids { let Ok(val) = serde_json::to_value(KernelRequest::ReloadCapsule { id: id.clone() }) else { @@ -117,20 +117,19 @@ pub(crate) async fn try_daemon_unload(capsule_id: &str) -> anyhow::Result anyhow::Result bail!("running daemon returned a malformed live capsule unload response"), } } + +async fn daemon_socket_reachable() -> bool { + let path = crate::socket_client::proxy_socket_path(); + path.exists() && tokio::net::UnixStream::connect(path).await.is_ok() +} + +fn classify_live_client( + result: crate::socket_client::WorkspaceConnectionResult, +) -> anyhow::Result> { + match result { + Ok(client) => Ok(Some(client)), + Err(crate::socket_client::WorkspaceConnectionError::Connect(_)) => Ok(None), + Err(crate::socket_client::WorkspaceConnectionError::Selection(error)) => Err(error), + } +} + +#[cfg(test)] +mod tests { + use super::classify_live_client; + use crate::socket_client::WorkspaceConnectionError; + + #[test] + fn live_client_classification_keeps_offline_and_mismatch_distinct() { + assert!( + classify_live_client::<()>(Err(WorkspaceConnectionError::Connect(anyhow::anyhow!( + "unreachable socket" + )))) + .unwrap() + .is_none() + ); + assert!( + classify_live_client::<()>(Err(WorkspaceConnectionError::Selection(anyhow::anyhow!( + "workspace mismatch" + )))) + .is_err() + ); + } +} diff --git a/crates/astrid-cli/src/commands/daemon.rs b/crates/astrid-cli/src/commands/daemon.rs index 247a842a6..d51b48521 100644 --- a/crates/astrid-cli/src/commands/daemon.rs +++ b/crates/astrid-cli/src/commands/daemon.rs @@ -5,7 +5,6 @@ use std::time::Duration; use anyhow::{Context, Result}; use astrid_core::kernel_api::{DaemonStatus, KernelRequest, KernelResponse}; -use astrid_uplink::KernelClient; use crate::bootstrap::find_companion_binary; use crate::commands::daemon_control; @@ -420,7 +419,7 @@ pub(crate) async fn handle_status() -> Result<()> { return Ok(()); } - let mut client = KernelClient::connect(crate::principal::current()) + let mut client = socket_client::connect_kernel_for_workspace(None) .await .context("Daemon socket exists but connection failed")?; let status = status_response( @@ -487,7 +486,9 @@ pub(crate) async fn handle_stop() -> Result<()> { } // Graceful path: the socket is present and serviceable. - if socket_present && let Ok(client) = KernelClient::connect(crate::principal::current()).await { + // Deliberately bypass the selected-workspace check: stopping a daemon is + // the recovery path when that daemon belongs to another project/layout. + if socket_present && let Ok(client) = socket_client::connect_kernel_for_recovery().await { let mut client = client.with_timeout(Duration::from_secs(10)); match client .request(KernelRequest::Shutdown { diff --git a/crates/astrid-cli/src/commands/doctor.rs b/crates/astrid-cli/src/commands/doctor.rs index ca875c01a..9fd5141e5 100644 --- a/crates/astrid-cli/src/commands/doctor.rs +++ b/crates/astrid-cli/src/commands/doctor.rs @@ -10,7 +10,6 @@ use std::time::Duration; use anyhow::Result; use astrid_core::dirs::AstridHome; use astrid_core::kernel_api::{KernelRequest, KernelResponse}; -use astrid_uplink::KernelClient; use clap::Args; use colored::Colorize; @@ -134,7 +133,7 @@ fn check_fail(name: &str, detail: &str) { async fn daemon_roundtrip() -> Result<()> { let mut client = tokio::time::timeout( Duration::from_secs(5), - KernelClient::connect(crate::principal::current()), + crate::socket_client::connect_kernel_for_workspace(None), ) .await .map_err(|_| anyhow::anyhow!("connection timed out after 5s"))??; @@ -161,7 +160,7 @@ async fn daemon_roundtrip() -> Result<()> { async fn agent_readiness() -> Result { let mut client = tokio::time::timeout( Duration::from_secs(5), - KernelClient::connect(crate::principal::current()), + crate::socket_client::connect_kernel_for_workspace(None), ) .await .map_err(|_| anyhow::anyhow!("connection timed out after 5s"))??; diff --git a/crates/astrid-cli/src/commands/invite.rs b/crates/astrid-cli/src/commands/invite.rs index 8c27a9fc7..ba3962700 100644 --- a/crates/astrid-cli/src/commands/invite.rs +++ b/crates/astrid-cli/src/commands/invite.rs @@ -14,7 +14,7 @@ use astrid_core::kernel_api::{AdminRequestKind, AdminResponseBody}; use clap::{Args, Subcommand}; use colored::Colorize; -use crate::admin_client::{AdminClient, connect_as_active_agent, into_result}; +use crate::admin_client::{connect_as_active_agent, connect_for_workspace_as, into_result}; use crate::theme::Theme; #[derive(Subcommand, Debug, Clone)] @@ -161,7 +161,7 @@ async fn run_redeem(args: RedeemArgs) -> Result { // `cli-context.toml` yet, so don't require an active-agent context // here; stamp the IPC message as `default` and let the kernel's // `InviteRedeem` dispatch path verify the token internally. - let mut client = AdminClient::connect(PrincipalId::default()) + let mut client = connect_for_workspace_as(PrincipalId::default()) .await .context("connect to daemon for invite redeem")?; let resp = client diff --git a/crates/astrid-cli/src/commands/ps.rs b/crates/astrid-cli/src/commands/ps.rs index c1019773b..efd1d65a7 100644 --- a/crates/astrid-cli/src/commands/ps.rs +++ b/crates/astrid-cli/src/commands/ps.rs @@ -9,7 +9,6 @@ use std::process::ExitCode; use anyhow::Result; use astrid_core::kernel_api::{KernelRequest, KernelResponse}; -use astrid_uplink::KernelClient; use clap::Args; use colored::Colorize; use serde::Serialize; @@ -47,9 +46,15 @@ pub(crate) async fn run(args: PsArgs) -> Result { } return Ok(ExitCode::SUCCESS); } - let Ok(mut client) = KernelClient::connect(crate::principal::current()).await else { - eprintln!("{}", Theme::error("Failed to connect to daemon")); - return Ok(ExitCode::from(1)); + let mut client = match crate::socket_client::connect_kernel_for_workspace(None).await { + Ok(client) => client, + Err(error) => { + eprintln!( + "{}", + Theme::error(&format!("Failed to connect to daemon: {error:#}")) + ); + return Ok(ExitCode::from(1)); + }, }; let entries = match client.request(KernelRequest::GetCapsuleMetadata).await { Ok(KernelResponse::CapsuleMetadata(list)) => list, diff --git a/crates/astrid-cli/src/commands/who.rs b/crates/astrid-cli/src/commands/who.rs index 7e733dea8..47fea7804 100644 --- a/crates/astrid-cli/src/commands/who.rs +++ b/crates/astrid-cli/src/commands/who.rs @@ -18,7 +18,6 @@ use std::process::ExitCode; use anyhow::Result; use astrid_core::kernel_api::{KernelRequest, KernelResponse}; -use astrid_uplink::KernelClient; use clap::Args; use colored::Colorize; use serde::Serialize; @@ -56,9 +55,15 @@ pub(crate) async fn run(args: WhoArgs) -> Result { } return Ok(ExitCode::SUCCESS); } - let Ok(mut client) = KernelClient::connect(crate::principal::current()).await else { - eprintln!("{}", Theme::error("Failed to connect to daemon")); - return Ok(ExitCode::from(1)); + let mut client = match crate::socket_client::connect_kernel_for_workspace(None).await { + Ok(client) => client, + Err(error) => { + eprintln!( + "{}", + Theme::error(&format!("Failed to connect to daemon: {error:#}")) + ); + return Ok(ExitCode::from(1)); + }, }; let status = match client.request(KernelRequest::GetStatus).await { Ok(KernelResponse::Status(status)) => status, diff --git a/crates/astrid-cli/src/socket_client.rs b/crates/astrid-cli/src/socket_client.rs index 9f7396ada..925ee7e15 100644 --- a/crates/astrid-cli/src/socket_client.rs +++ b/crates/astrid-cli/src/socket_client.rs @@ -9,15 +9,107 @@ pub(crate) use astrid_uplink::socket_client::{ SocketClient, pid_path, proxy_socket_path, readiness_path, }; +use std::future::Future; + use anyhow::Result; +use astrid_uplink::KernelClient; + +enum KernelConnectionScope<'a> { + Workspace(Option<&'a std::path::Path>), + Recovery, +} + +impl<'a> KernelConnectionScope<'a> { + fn workspace_check(self) -> Option> { + match self { + Self::Workspace(workspace_root) => Some(workspace_root), + Self::Recovery => None, + } + } +} pub(crate) async fn connect_for_workspace( session: astrid_core::SessionId, principal: astrid_core::PrincipalId, workspace_root: Option<&std::path::Path>, -) -> Result { - let client = SocketClient::connect(session, principal).await?; - crate::commands::daemon::ensure_daemon_workspace_matches(workspace_root).await?; +) -> WorkspaceConnectionResult { + connect_workspace_client(workspace_root, || SocketClient::connect(session, principal)).await +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum WorkspaceConnectionError { + #[error("{0:#}")] + Selection(#[source] anyhow::Error), + #[error("{0:#}")] + Connect(#[source] anyhow::Error), +} + +pub(crate) type WorkspaceConnectionResult = std::result::Result; + +/// Connect any workspace-sensitive daemon client between two selection checks. +/// +/// Checking before the connection prevents authentication against the wrong +/// daemon. Checking again afterwards catches a daemon restart that retargeted +/// the global socket during the handshake. +pub(crate) async fn connect_workspace_client( + workspace_root: Option<&std::path::Path>, + connect: Connect, +) -> WorkspaceConnectionResult +where + Connect: FnOnce() -> ConnectFuture, + ConnectFuture: Future>, +{ + connect_between_workspace_checks( + || crate::commands::daemon::ensure_daemon_workspace_matches(workspace_root), + connect, + ) + .await +} + +/// Connect a kernel-management client for state belonging to the selected +/// project and workspace layout. +/// +/// Readiness metadata must match before the management socket is opened, so a +/// mismatched CLI neither authenticates nor issues a project-sensitive request +/// to a daemon owned by another project. +pub(crate) async fn connect_kernel_for_workspace( + workspace_root: Option<&std::path::Path>, +) -> Result { + connect_kernel(KernelConnectionScope::Workspace(workspace_root)).await +} + +/// Connect for a daemon lifecycle recovery operation. +/// +/// Recovery must remain possible when the running daemon belongs to another +/// project or layout. Keep this restricted to operations such as `stop` that +/// terminate the process and do not read or mutate project-owned daemon state. +pub(crate) async fn connect_kernel_for_recovery() -> Result { + connect_kernel(KernelConnectionScope::Recovery).await +} + +async fn connect_kernel(scope: KernelConnectionScope<'_>) -> Result { + if let Some(workspace_root) = scope.workspace_check() { + return Ok(connect_workspace_client(workspace_root, || { + KernelClient::connect(crate::principal::current()) + }) + .await?); + } + KernelClient::connect(crate::principal::current()).await +} + +async fn connect_between_workspace_checks( + mut check: Check, + connect: Connect, +) -> WorkspaceConnectionResult +where + Check: FnMut() -> CheckFuture, + CheckFuture: Future>, + Connect: FnOnce() -> ConnectFuture, + ConnectFuture: Future>, +{ + check().await.map_err(WorkspaceConnectionError::Selection)?; + let client = connect().await.map_err(WorkspaceConnectionError::Connect)?; + check().await.map_err(WorkspaceConnectionError::Selection)?; Ok(client) } @@ -37,3 +129,90 @@ pub(crate) async fn send_input_as_active_agent( ) -> Result<()> { client.send_input(text).await } + +#[cfg(test)] +mod tests { + use std::cell::Cell; + use std::future; + use std::path::Path; + + use super::{ + KernelConnectionScope, WorkspaceConnectionError, connect_between_workspace_checks, + }; + + #[test] + fn kernel_connection_scope_checks_project_reads_but_keeps_recovery_global() { + let explicit = Path::new("/selected/project"); + assert_eq!( + KernelConnectionScope::Workspace(Some(explicit)).workspace_check(), + Some(Some(explicit)) + ); + assert_eq!( + KernelConnectionScope::Workspace(None).workspace_check(), + Some(None) + ); + assert_eq!(KernelConnectionScope::Recovery.workspace_check(), None); + } + + #[tokio::test] + async fn workspace_mismatch_prevents_connection() { + let connected = Cell::new(false); + let result = connect_between_workspace_checks( + || future::ready(Err(anyhow::anyhow!("workspace mismatch"))), + || async { + connected.set(true); + Ok(()) + }, + ) + .await; + + assert!(matches!( + result, + Err(WorkspaceConnectionError::Selection(_)) + )); + assert!(!connected.get()); + } + + #[tokio::test] + async fn post_connection_mismatch_rejects_retargeted_client() { + let checks = Cell::new(0_u8); + let connected = Cell::new(false); + let result = connect_between_workspace_checks( + || { + checks.set(checks.get() + 1); + future::ready(if checks.get() == 1 { + Ok(()) + } else { + Err(anyhow::anyhow!("daemon restarted for another workspace")) + }) + }, + || async { + connected.set(true); + Ok(()) + }, + ) + .await + .unwrap_err(); + + assert!(matches!(result, WorkspaceConnectionError::Selection(_))); + assert!(connected.get()); + assert_eq!(checks.get(), 2); + } + + #[tokio::test] + async fn matching_workspace_connects_between_two_checks() { + let checks = Cell::new(0_u8); + let client = connect_between_workspace_checks( + || { + checks.set(checks.get() + 1); + future::ready(Ok(())) + }, + || async { Ok(42_u8) }, + ) + .await + .unwrap(); + + assert_eq!(client, 42); + assert_eq!(checks.get(), 2); + } +} From 7153674115bdad15de395a00dd5b7b9d4f84f19b Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 11:10:34 +0400 Subject: [PATCH 12/20] Make workspace recovery scope explicit --- crates/astrid-cli/src/socket_client.rs | 31 ++++++++++++++++---------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/crates/astrid-cli/src/socket_client.rs b/crates/astrid-cli/src/socket_client.rs index 925ee7e15..e224f29e5 100644 --- a/crates/astrid-cli/src/socket_client.rs +++ b/crates/astrid-cli/src/socket_client.rs @@ -20,9 +20,13 @@ enum KernelConnectionScope<'a> { } impl<'a> KernelConnectionScope<'a> { - fn workspace_check(self) -> Option> { + const fn requires_workspace_check(&self) -> bool { + matches!(self, Self::Workspace(_)) + } + + fn workspace_root(self) -> Option<&'a std::path::Path> { match self { - Self::Workspace(workspace_root) => Some(workspace_root), + Self::Workspace(workspace_root) => workspace_root, Self::Recovery => None, } } @@ -88,7 +92,8 @@ pub(crate) async fn connect_kernel_for_recovery() -> Result { } async fn connect_kernel(scope: KernelConnectionScope<'_>) -> Result { - if let Some(workspace_root) = scope.workspace_check() { + if scope.requires_workspace_check() { + let workspace_root = scope.workspace_root(); return Ok(connect_workspace_client(workspace_root, || { KernelClient::connect(crate::principal::current()) }) @@ -143,15 +148,17 @@ mod tests { #[test] fn kernel_connection_scope_checks_project_reads_but_keeps_recovery_global() { let explicit = Path::new("/selected/project"); - assert_eq!( - KernelConnectionScope::Workspace(Some(explicit)).workspace_check(), - Some(Some(explicit)) - ); - assert_eq!( - KernelConnectionScope::Workspace(None).workspace_check(), - Some(None) - ); - assert_eq!(KernelConnectionScope::Recovery.workspace_check(), None); + let explicit_scope = KernelConnectionScope::Workspace(Some(explicit)); + assert!(explicit_scope.requires_workspace_check()); + assert_eq!(explicit_scope.workspace_root(), Some(explicit)); + + let default_scope = KernelConnectionScope::Workspace(None); + assert!(default_scope.requires_workspace_check()); + assert_eq!(default_scope.workspace_root(), None); + + let recovery_scope = KernelConnectionScope::Recovery; + assert!(!recovery_scope.requires_workspace_check()); + assert_eq!(recovery_scope.workspace_root(), None); } #[tokio::test] From abdf40a0817a005d9fbfdc3f05ad2a721a13de19 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 11:22:22 +0400 Subject: [PATCH 13/20] Harden workspace-aware CLI reconnects --- CHANGELOG.md | 7 +- crates/astrid-cli/src/bootstrap.rs | 29 ++++- crates/astrid-cli/src/commands/daemon.rs | 67 ++++++++-- crates/astrid-cli/src/commands/mcp/mod.rs | 2 +- crates/astrid-cli/src/commands/mcp/server.rs | 69 +++++++--- crates/astrid-cli/src/socket_client.rs | 130 +++++++++++++++++++ 6 files changed, 265 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b001d7346..301cd6bd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,9 +91,10 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. relative directory name through `--workspace-state-dir` or `ASTRID_WORKSPACE_STATE_DIR`. Config, capsule installation and discovery, kernel boot, gateway source checks, hooks, and WIT garbage collection share - the selected layout and never scan both project roots. CLI uplinks and - project-sensitive management reads reject a daemon booted for a different - project or layout; daemon stop remains available as a recovery operation. + the selected layout and never scan both project roots. CLI uplinks, including + long-lived MCP reconnects, and project-sensitive management reads reject a + daemon booted for a different project or layout; daemon stop remains + available as a recovery operation. - **Runtime E2E now stages the pinned Unicity AOS monorepo.** The workflow preserves the AOS Cargo workspace outside the core checkout and supplies diff --git a/crates/astrid-cli/src/bootstrap.rs b/crates/astrid-cli/src/bootstrap.rs index 055e290c3..f7253f690 100644 --- a/crates/astrid-cli/src/bootstrap.rs +++ b/crates/astrid-cli/src/bootstrap.rs @@ -132,6 +132,10 @@ pub(crate) fn run_build_companion( } } +fn selected_workspace_root(workspace: Option) -> Option { + workspace.or_else(|| std::env::current_dir().ok()) +} + /// Resolve the session, check for an existing socket, and boot the /// kernel locally if necessary. Drives the interactive-session path. /// @@ -152,6 +156,7 @@ pub(crate) async fn run_or_connect( } else { SessionId::from_uuid(Uuid::new_v4()) }; + let workspace_root = selected_workspace_root(workspace); let socket_path = socket_client::proxy_socket_path(); let ready_path = socket_client::readiness_path(); @@ -186,7 +191,7 @@ pub(crate) async fn run_or_connect( let mut daemon_child: Option = None; if needs_boot { - match commands::daemon::spawn_daemon(&ready_path).await { + match commands::daemon::spawn_daemon(&ready_path, workspace_root.as_deref()).await { Ok(child) => daemon_child = Some(child), Err(e) => return Err(e), } @@ -195,7 +200,7 @@ pub(crate) async fn run_or_connect( let mut client = match socket_client::connect_for_workspace( session_id.clone(), crate::principal::current(), - workspace.as_deref(), + workspace_root.as_deref(), ) .await { @@ -221,7 +226,6 @@ pub(crate) async fn run_or_connect( }, }; - let workspace_root = std::env::current_dir().ok(); let model_name = astrid_config::Config::load_with_layout( workspace_root.as_deref(), crate::workspace_layout::current(), @@ -231,3 +235,22 @@ pub(crate) async fn run_or_connect( crate::commands::chat::run_chat(&mut client, &session_id, &model_name, format).await } + +#[cfg(test)] +mod tests { + use super::selected_workspace_root; + + #[test] + fn explicit_workspace_root_wins_over_current_directory() { + let explicit = tempfile::tempdir().expect("explicit workspace"); + assert_eq!( + selected_workspace_root(Some(explicit.path().to_path_buf())).as_deref(), + Some(explicit.path()) + ); + } + + #[test] + fn absent_workspace_root_uses_current_directory() { + assert_eq!(selected_workspace_root(None), std::env::current_dir().ok()); + } +} diff --git a/crates/astrid-cli/src/commands/daemon.rs b/crates/astrid-cli/src/commands/daemon.rs index d51b48521..a540302cb 100644 --- a/crates/astrid-cli/src/commands/daemon.rs +++ b/crates/astrid-cli/src/commands/daemon.rs @@ -64,29 +64,27 @@ fn boot_log_stderr() -> Option { /// # Errors /// Returns an error if the daemon binary is not found, fails to spawn, or /// doesn't become ready within the bounded startup window. -pub(crate) async fn spawn_daemon(ready_path: &std::path::Path) -> Result { - spawn_daemon_inner(ready_path, true).await +pub(crate) async fn spawn_daemon( + ready_path: &std::path::Path, + workspace_root: Option<&Path>, +) -> Result { + spawn_daemon_inner(ready_path, true, workspace_root).await } async fn spawn_daemon_inner( ready_path: &std::path::Path, announce: bool, + workspace_root: Option<&Path>, ) -> Result { if announce { println!("{}", theme::Theme::info("Booting Astrid daemon...")); } - let ws = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); - let daemon_bin = find_companion_binary("astrid-daemon")?; - - let mut cmd = std::process::Command::new(daemon_bin); - cmd.arg("--ephemeral").env( - "ASTRID_WORKSPACE_STATE_DIR", - crate::workspace_layout::current().state_dir_name(), + let ws = workspace_root.map_or_else( + || std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), + Path::to_path_buf, ); - - if let Some(ws_path) = ws.to_str() { - cmd.arg("--workspace").arg(ws_path); - } + let daemon_bin = find_companion_binary("astrid-daemon")?; + let mut cmd = ephemeral_daemon_command(&daemon_bin, &ws); // Capture the daemon's stderr to an append log so a boot failure (lock // contention, panic before tracing init) leaves a record instead of @@ -136,6 +134,18 @@ async fn spawn_daemon_inner( Ok(child) } +fn ephemeral_daemon_command(daemon_bin: &Path, workspace_root: &Path) -> std::process::Command { + let mut cmd = std::process::Command::new(daemon_bin); + cmd.arg("--ephemeral") + .arg("--workspace") + .arg(workspace_root) + .env( + "ASTRID_WORKSPACE_STATE_DIR", + crate::workspace_layout::current().state_dir_name(), + ); + cmd +} + /// Ensure the daemon is running, spawning it if needed. /// /// Checks the socket path, cleans up stale sockets, and spawns a fresh @@ -171,7 +181,7 @@ async fn ensure_daemon_inner(label: &str, announce: bool) -> Result<()> { true }; if needs_boot { - spawn_daemon_inner(&ready_path, announce).await?; + spawn_daemon_inner(&ready_path, announce, None).await?; ensure_daemon_workspace_matches(None).await?; } Ok(()) @@ -705,6 +715,35 @@ mod tests { ); } + #[test] + fn ephemeral_boot_passes_the_selected_workspace_to_the_daemon() { + use std::ffi::OsStr; + + let command = ephemeral_daemon_command( + Path::new("/installed/astrid-daemon"), + Path::new("/selected/project"), + ); + let args = command.get_args().collect::>(); + + assert_eq!( + args, + vec![ + OsStr::new("--ephemeral"), + OsStr::new("--workspace"), + OsStr::new("/selected/project"), + ] + ); + assert_eq!( + command + .get_envs() + .find(|(name, _)| *name == OsStr::new("ASTRID_WORKSPACE_STATE_DIR")) + .and_then(|(_, value)| value), + Some(OsStr::new( + crate::workspace_layout::current().state_dir_name() + )) + ); + } + /// REGRESSION (#1120): `astrid stop` must remove the socket/PID files ONLY /// when the daemon is confirmed gone. Before the fix, stop reported success /// and deleted the PID file on the shutdown ACK alone; a daemon that then diff --git a/crates/astrid-cli/src/commands/mcp/mod.rs b/crates/astrid-cli/src/commands/mcp/mod.rs index f8d2277e2..61e239bf3 100644 --- a/crates/astrid-cli/src/commands/mcp/mod.rs +++ b/crates/astrid-cli/src/commands/mcp/mod.rs @@ -137,7 +137,7 @@ pub(crate) async fn serve(principal: Option<&str>) -> Result { tokio::spawn(session_guard::run(caller.clone())); - let server = AstridMcpServer::new(Arc::new(Mutex::new(client)), caller.to_string()); + let server = AstridMcpServer::new(Arc::new(Mutex::new(client)), caller.clone()); // `rmcp::transport::stdio()` yields the (stdin, stdout) pair the MCP // transport drives. `serve` performs the MCP handshake and spawns the diff --git a/crates/astrid-cli/src/commands/mcp/server.rs b/crates/astrid-cli/src/commands/mcp/server.rs index 05c2c9e7a..9cca3ca5e 100644 --- a/crates/astrid-cli/src/commands/mcp/server.rs +++ b/crates/astrid-cli/src/commands/mcp/server.rs @@ -38,6 +38,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; +use astrid_core::PrincipalId; use astrid_uplink::socket_client::ReadError; use rmcp::ErrorData as McpError; use rmcp::ServerHandler; @@ -102,7 +103,7 @@ pub(crate) struct AstridMcpServer { /// The single long-lived uplink, shared across `&self` handlers. client: Arc>, /// Principal stamped on every outbound IPC message. - principal: String, + principal: PrincipalId, /// Set when a prior round trip ended in a connection-loss condition (a /// failed send, or a reply read that hit EOF / reset). The NEXT round trip /// re-handshakes (`reconnect()`) before sending, so a request never goes @@ -118,7 +119,7 @@ pub(crate) struct AstridMcpServer { impl AstridMcpServer { /// Build a new shim over an established uplink and resolved principal. - pub(crate) fn new(client: Arc>, principal: String) -> Self { + pub(crate) fn new(client: Arc>, principal: PrincipalId) -> Self { Self { client, principal, @@ -126,6 +127,30 @@ impl AstridMcpServer { } } + /// Reconnect the held uplink without ever retaining a client authenticated + /// to a daemon for another workspace. Every failure leaves the recovery + /// flag armed; only a fully checked replacement clears it. + async fn reconnect(&self, client: &mut SocketClient) -> Result<(), McpError> { + self.needs_reconnect.store(true, Ordering::Relaxed); + let principal = self.principal.clone(); + let result = crate::socket_client::reconnect_for_workspace( + client, + principal.clone(), + None, + |replacement| { + super::require_authenticated_unless_anonymous( + &principal, + replacement.is_authenticated(), + ) + }, + ) + .await; + record_reconnect_outcome(&self.needs_reconnect, &result); + result.map_err(|error| { + McpError::internal_error(format!("reconnect to daemon failed: {error}"), None) + }) + } + /// Publish `body` on `request_topic` and await the broker reply on /// `astrid.v1.response.`, returning the inner reply object. /// @@ -148,7 +173,7 @@ impl AstridMcpServer { // CLI uplink verbs and send nil. Uuid::nil(), ) - .with_principal(self.principal.clone()); + .with_principal(self.principal.to_string()); let mut client = self.client.lock().await; @@ -159,20 +184,14 @@ impl AstridMcpServer { // fully-authenticated connection rather than silently dropping into a // half-open socket. A clean deadline against a slow broker never sets // the flag, so a slow tool does not force a needless reconnect. - if self.needs_reconnect.swap(false, Ordering::Relaxed) { + if self.needs_reconnect.load(Ordering::Relaxed) { warn!( topic = request_topic, "MCP shim: pre-healing a connection flagged dead by a prior round trip" ); - if let Err(e) = client.reconnect().await { - // Re-arm the flag: the connection is still dead, so the next - // attempt must try to heal again rather than assume health. - self.needs_reconnect.store(true, Ordering::Relaxed); + if let Err(e) = self.reconnect(&mut client).await { warn!(error = %e, "MCP shim: pre-heal reconnect failed"); - return Err(McpError::internal_error( - format!("reconnect to daemon failed: {e}"), - None, - )); + return Err(e); } } @@ -185,11 +204,12 @@ impl AstridMcpServer { // retried for idempotent requests). if let Err(first) = client.send_message(msg.clone()).await { warn!(topic = request_topic, error = %first, "MCP shim: broker publish failed; reconnecting to daemon and retrying once"); - client.reconnect().await.map_err(|e| { + self.reconnect(&mut client).await.map_err(|e| { warn!(error = %e, "MCP shim: reconnect to daemon failed"); - McpError::internal_error(format!("reconnect to daemon failed: {e}"), None) + e })?; client.send_message(msg.clone()).await.map_err(|e| { + self.needs_reconnect.store(true, Ordering::Relaxed); warn!(topic = request_topic, error = %e, "MCP shim: failed to publish broker request after reconnect"); McpError::internal_error(format!("failed to publish broker request after reconnect: {e}"), None) })?; @@ -214,13 +234,12 @@ impl AstridMcpServer { self.needs_reconnect.store(true, Ordering::Relaxed); if is_request_retriable(request_topic) { warn!(topic = request_topic, error = %e, "MCP shim: connection lost awaiting reply; reconnecting and retrying idempotent request once"); - client.reconnect().await.map_err(|re| { + self.reconnect(&mut client).await.map_err(|re| { warn!(error = %re, "MCP shim: reconnect for idempotent retry failed"); - McpError::internal_error(format!("reconnect to daemon failed: {re}"), None) + re })?; - // Healed in-line; clear the flag we just set. - self.needs_reconnect.store(false, Ordering::Relaxed); client.send_message(msg).await.map_err(|se| { + self.needs_reconnect.store(true, Ordering::Relaxed); warn!(topic = request_topic, error = %se, "MCP shim: re-publish after reconnect failed"); McpError::internal_error( format!("failed to re-publish broker request after reconnect: {se}"), @@ -269,6 +288,10 @@ impl AstridMcpServer { } } +fn record_reconnect_outcome(needs_reconnect: &AtomicBool, result: &Result) { + needs_reconnect.store(result.is_err(), Ordering::Relaxed); +} + /// Whether a broker round trip on `request_topic` may be transparently /// re-issued after the connection drops mid-wait, WITHOUT risking a duplicate /// side effect. @@ -736,6 +759,16 @@ fn content_from_block(block: &Value) -> ContentBlock { mod tests { use super::*; + #[test] + fn failed_reconnect_remains_armed_until_a_checked_replacement_succeeds() { + let needs_reconnect = AtomicBool::new(false); + record_reconnect_outcome(&needs_reconnect, &Err::<(), _>("mismatch")); + assert!(needs_reconnect.load(Ordering::Relaxed)); + + record_reconnect_outcome(&needs_reconnect, &Ok::<_, &str>(())); + assert!(!needs_reconnect.load(Ordering::Relaxed)); + } + /// The read-only tool-enumeration front door (MCP `tools/list`) is the only /// round trip safe to transparently re-issue after a mid-wait connection /// loss: it never mutates state. diff --git a/crates/astrid-cli/src/socket_client.rs b/crates/astrid-cli/src/socket_client.rs index e224f29e5..0cfa2b8b2 100644 --- a/crates/astrid-cli/src/socket_client.rs +++ b/crates/astrid-cli/src/socket_client.rs @@ -12,6 +12,7 @@ pub(crate) use astrid_uplink::socket_client::{ use std::future::Future; use anyhow::Result; +use astrid_core::PrincipalId; use astrid_uplink::KernelClient; enum KernelConnectionScope<'a> { @@ -40,6 +41,31 @@ pub(crate) async fn connect_for_workspace( connect_workspace_client(workspace_root, || SocketClient::connect(session, principal)).await } +/// Transactionally reconnect a long-lived uplink to the selected workspace. +/// +/// A replacement client is authenticated between the same pre/post selection +/// checks as an initial connection. The held client is swapped only after the +/// post-check succeeds, so a daemon restart into another workspace can never +/// leave the long-lived client bound to that daemon after returning an error. +pub(crate) async fn reconnect_for_workspace( + client: &mut SocketClient, + principal: PrincipalId, + workspace_root: Option<&std::path::Path>, + validate: Validate, +) -> WorkspaceConnectionResult<()> +where + Validate: FnOnce(&SocketClient) -> Result<()>, +{ + let session = client.session_id.clone(); + replace_between_workspace_checks( + client, + || crate::commands::daemon::ensure_daemon_workspace_matches(workspace_root), + || SocketClient::connect(session, principal), + validate, + ) + .await +} + #[derive(Debug, thiserror::Error)] pub(crate) enum WorkspaceConnectionError { #[error("{0:#}")] @@ -118,6 +144,24 @@ where Ok(client) } +async fn replace_between_workspace_checks( + current: &mut T, + check: Check, + connect: Connect, + validate: impl FnOnce(&T) -> Result<()>, +) -> WorkspaceConnectionResult<()> +where + Check: FnMut() -> CheckFuture, + CheckFuture: Future>, + Connect: FnOnce() -> ConnectFuture, + ConnectFuture: Future>, +{ + let replacement = connect_between_workspace_checks(check, connect).await?; + validate(&replacement).map_err(WorkspaceConnectionError::Connect)?; + *current = replacement; + Ok(()) +} + /// Send a user prompt over an established uplink. /// /// The connection is already bound to the process principal (resolved @@ -143,6 +187,7 @@ mod tests { use super::{ KernelConnectionScope, WorkspaceConnectionError, connect_between_workspace_checks, + replace_between_workspace_checks, }; #[test] @@ -222,4 +267,89 @@ mod tests { assert_eq!(client, 42); assert_eq!(checks.get(), 2); } + + #[tokio::test] + async fn pre_reconnect_mismatch_never_dials_or_replaces() { + let dialed = Cell::new(false); + let mut client = 7_u8; + let result = replace_between_workspace_checks( + &mut client, + || future::ready(Err(anyhow::anyhow!("workspace mismatch"))), + || async { + dialed.set(true); + Ok(9_u8) + }, + |_| Ok(()), + ) + .await; + + assert!(matches!( + result, + Err(WorkspaceConnectionError::Selection(_)) + )); + assert!(!dialed.get()); + assert_eq!(client, 7); + } + + #[tokio::test] + async fn post_reconnect_mismatch_keeps_the_previous_client() { + let checks = Cell::new(0_u8); + let mut client = 7_u8; + let result = replace_between_workspace_checks( + &mut client, + || { + checks.set(checks.get() + 1); + future::ready(if checks.get() == 1 { + Ok(()) + } else { + Err(anyhow::anyhow!("daemon retargeted during reconnect")) + }) + }, + || async { Ok(9_u8) }, + |_| Ok(()), + ) + .await; + + assert!(matches!( + result, + Err(WorkspaceConnectionError::Selection(_)) + )); + assert_eq!(checks.get(), 2); + assert_eq!(client, 7); + } + + #[tokio::test] + async fn matching_reconnect_replaces_only_after_both_checks() { + let checks = Cell::new(0_u8); + let mut client = 7_u8; + replace_between_workspace_checks( + &mut client, + || { + checks.set(checks.get() + 1); + future::ready(Ok(())) + }, + || async { Ok(9_u8) }, + |_| Ok(()), + ) + .await + .unwrap(); + + assert_eq!(checks.get(), 2); + assert_eq!(client, 9); + } + + #[tokio::test] + async fn rejected_reconnect_candidate_does_not_replace_the_client() { + let mut client = 7_u8; + let result = replace_between_workspace_checks( + &mut client, + || future::ready(Ok(())), + || async { Ok(9_u8) }, + |_| Err(anyhow::anyhow!("replacement authentication failed")), + ) + .await; + + assert!(matches!(result, Err(WorkspaceConnectionError::Connect(_)))); + assert_eq!(client, 7); + } } From 17d569e5ea75ee67f25c84d571b7f75bea2403c9 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 20:14:03 +0400 Subject: [PATCH 14/20] test(gateway): compose workspace and audit builders --- crates/astrid-gateway/tests/router.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/astrid-gateway/tests/router.rs b/crates/astrid-gateway/tests/router.rs index 2e61bedb9..8854cda5c 100644 --- a/crates/astrid-gateway/tests/router.rs +++ b/crates/astrid-gateway/tests/router.rs @@ -254,7 +254,7 @@ async fn me_route_returns_principal_from_valid_bearer() { } #[tokio::test] -async fn public_router_builder_accepts_live_capability_probe() { +async fn public_router_builders_accept_live_capability_probe() { let (state, audit_log, session_id) = fresh_state_with_audit_log(); audit_log .append_with_principal( @@ -274,7 +274,14 @@ async fn public_router_builder_accepts_live_capability_probe() { ) .await .expect("append audit entry"); - let router = routes::build_with_capability_probe(Arc::clone(&state), |_, _, _| true); + let _default_workspace_router = + routes::build_with_capability_probe(Arc::clone(&state), |_, _, _| true); + let router = routes::build_with_workspace_and_capability_probe( + Arc::clone(&state), + std::env::temp_dir(), + astrid_core::dirs::WorkspaceLayout::new(".custom-aos").unwrap(), + |_, _, _| true, + ); let principal = PrincipalId::new("alice").unwrap(); let bearer = mint_bearer(&state.signing.signer, &principal, 3600); From 838085220a5cb2c3ab2b2a64d53aecf5df4f1a22 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 20:31:22 +0400 Subject: [PATCH 15/20] fix(cli): bind init grants to selected workspace --- crates/astrid-cli/src/admin_client.rs | 26 +++++++++++++++----- crates/astrid-cli/src/commands/init_grant.rs | 8 +++--- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/crates/astrid-cli/src/admin_client.rs b/crates/astrid-cli/src/admin_client.rs index 7eb6ed78c..6566bb7d0 100644 --- a/crates/astrid-cli/src/admin_client.rs +++ b/crates/astrid-cli/src/admin_client.rs @@ -1,13 +1,27 @@ -//! CLI re-export shim for the shared +//! Workspace-checked CLI wrapper for the shared //! [`astrid_uplink::AdminClient`](::astrid_uplink::admin_client::AdminClient). //! //! The CLI binds the admin client to the process-wide principal //! (resolved once at startup from `--principal` / `ASTRID_PRINCIPAL` / //! `default`) — a fresh client per call, all stamping one identity. -pub(crate) use astrid_uplink::admin_client::{AdminClient, into_result}; +pub(crate) use astrid_uplink::admin_client::into_result; use anyhow::Result; +use astrid_core::kernel_api::{AdminRequestKind, AdminResponseBody}; +use astrid_uplink::admin_client::AdminClient as UplinkAdminClient; + +/// Admin request surface available to CLI commands. +/// +/// Construction stays private to this module so command code cannot bypass +/// the selected-workspace checks in [`connect_for_workspace_as`]. +pub(crate) struct AdminClient(UplinkAdminClient); + +impl AdminClient { + pub(crate) async fn request(&mut self, kind: AdminRequestKind) -> Result { + self.0.request(kind).await + } +} /// Connect to the daemon as the process principal. /// @@ -34,8 +48,8 @@ pub(crate) async fn connect_as_active_agent() -> Result { pub(crate) async fn connect_for_workspace_as( caller: astrid_core::PrincipalId, ) -> Result { - Ok( - crate::socket_client::connect_workspace_client(None, || AdminClient::connect(caller)) - .await?, - ) + let client = + crate::socket_client::connect_workspace_client(None, || UplinkAdminClient::connect(caller)) + .await?; + Ok(AdminClient(client)) } diff --git a/crates/astrid-cli/src/commands/init_grant.rs b/crates/astrid-cli/src/commands/init_grant.rs index 1ac941389..ebb149e2f 100644 --- a/crates/astrid-cli/src/commands/init_grant.rs +++ b/crates/astrid-cli/src/commands/init_grant.rs @@ -91,9 +91,9 @@ pub(super) async fn preflight_grants( preflight_sequence( crate::commands::daemon::ensure_daemon("init grant preflight"), || async move { - let mut client = crate::admin_client::AdminClient::connect(operator.clone()) + let mut client = crate::admin_client::connect_for_workspace_as(operator.clone()) .await - .context("grant preflight could not connect to the daemon")?; + .context("grant preflight could not connect to the selected workspace daemon")?; let body = client .request(AdminRequestKind::AgentModify { principal: target.clone(), @@ -384,11 +384,11 @@ async fn grant_installed_capsules( )) ); - let mut client = match crate::admin_client::AdminClient::connect(operator.clone()).await { + let mut client = match crate::admin_client::connect_for_workspace_as(operator.clone()).await { Ok(c) => c, Err(e) => { bail!( - "capsules are installed, but connecting to the daemon to grant access failed: {e}\n \ + "capsules are installed, but connecting to the selected workspace daemon to grant access failed: {e}\n \ Grant them once the daemon is running:\n {}", agent_modify_grant_command(operator, target, installed) ); From 25abb006643968329447494cf69fd81b5c9737ff Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 20:31:23 +0400 Subject: [PATCH 16/20] test(gateway): prove composed builder contracts --- crates/astrid-gateway/src/routes/mod.rs | 73 ++++++++++++++++++++++++- crates/astrid-gateway/tests/router.rs | 21 +++++-- 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/crates/astrid-gateway/src/routes/mod.rs b/crates/astrid-gateway/src/routes/mod.rs index da007e731..f6b3b75ec 100644 --- a/crates/astrid-gateway/src/routes/mod.rs +++ b/crates/astrid-gateway/src/routes/mod.rs @@ -30,6 +30,16 @@ impl Default for WorkspaceContext { } } +#[cfg(test)] +async fn get_workspace_context_probe( + Extension(workspace): Extension, +) -> axum::Json { + axum::Json(serde_json::json!({ + "root": workspace.root, + "state_dir": workspace.layout.state_dir_name(), + })) +} + /// Map a bus-direct / socket kernel-request failure ([`KernelClientError`]) to a /// [`GatewayError`]. Single-sourced so every `kernel_client_for(...).request()` /// call site maps consistently. @@ -189,6 +199,12 @@ pub(crate) fn build_with_workspace_and_probe( // a bearer. .route("/api/openapi.json", get(crate::openapi::get_openapi)); + #[cfg(test)] + let public = public.route( + "/__test/workspace-context", + get(get_workspace_context_probe), + ); + // Authenticated routes — bearer required, principal attached to // request extensions. let authed = build_authed_router(&state); @@ -548,10 +564,65 @@ fn intern_route(s: &str) -> &'static str { #[cfg(test)] mod tests { + use std::sync::Arc; + use astrid_uplink::{KernelClientError, TimeoutKind}; + use axum::body::{Body, to_bytes}; + use axum::http::Request; + use tower::ServiceExt; - use super::daemon_kernel_error; + use super::{build_with_workspace_and_capability_probe, daemon_kernel_error}; use crate::error::GatewayError; + use crate::state::{GatewayState, SigningMaterial}; + + fn test_state() -> Arc { + Arc::new(GatewayState { + config: crate::config::GatewayConfig::default(), + signing: SigningMaterial::fresh(), + event_bus: None, + distribution: Arc::new(crate::routes::distribution::DistributionInfo::single_tenant()), + onboarding: Arc::new(crate::routes::distribution::OnboardingFields::default()), + redeem_limiter: tokio::sync::Mutex::default(), + metrics_handle: crate::metrics::install_recorder().expect("recorder"), + revoked_at: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())), + revoked_key_ids: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())), + audit_log: None, + session_id: None, + gateway_route_uuid: uuid::Uuid::new_v4(), + readiness_probe: None, + topic_probe: None, + registry_timeout: None, + }) + } + + #[tokio::test] + async fn composed_builder_propagates_explicit_workspace_context() { + let root = tempfile::tempdir().expect("workspace root"); + let layout = astrid_core::dirs::WorkspaceLayout::new(".alternate-runtime") + .expect("workspace layout"); + let router = build_with_workspace_and_capability_probe( + test_state(), + root.path().to_path_buf(), + layout, + |_, _, _| false, + ); + let response = router + .oneshot( + Request::builder() + .uri("/__test/workspace-context") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + let body = to_bytes(response.into_body(), 4096) + .await + .expect("response body"); + let body: serde_json::Value = serde_json::from_slice(&body).expect("response json"); + + assert_eq!(body["root"], root.path().display().to_string()); + assert_eq!(body["state_dir"], ".alternate-runtime"); + } /// The shared kernel-request error mapper turns a typed `Timeout` into a 504 /// `GatewayError::Timeout` (retryable — the daemon was slow/wedged, e.g. a diff --git a/crates/astrid-gateway/tests/router.rs b/crates/astrid-gateway/tests/router.rs index 8854cda5c..840632694 100644 --- a/crates/astrid-gateway/tests/router.rs +++ b/crates/astrid-gateway/tests/router.rs @@ -16,7 +16,10 @@ //! 3. **The principal a client claims in the request body is //! ignored** — the verified bearer is the only source of truth. -use std::sync::Arc; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; use astrid_audit::{AuditAction, AuditLog, AuditOutcome, AuthorizationProof}; use astrid_core::PrincipalId; @@ -274,13 +277,20 @@ async fn public_router_builders_accept_live_capability_probe() { ) .await .expect("append audit entry"); - let _default_workspace_router = - routes::build_with_capability_probe(Arc::clone(&state), |_, _, _| true); + let _default_workspace_router = routes::build_with_capability_probe(Arc::clone(&state), { + let marker = String::from("captured-default-probe"); + move |_, _, _| !marker.is_empty() + }); + let probe_called = Arc::new(AtomicBool::new(false)); + let probe_called_by_router = Arc::clone(&probe_called); let router = routes::build_with_workspace_and_capability_probe( Arc::clone(&state), std::env::temp_dir(), - astrid_core::dirs::WorkspaceLayout::new(".custom-aos").unwrap(), - |_, _, _| true, + astrid_core::dirs::WorkspaceLayout::new(".alternate-runtime").unwrap(), + move |_, _, _| { + probe_called_by_router.store(true, Ordering::SeqCst); + true + }, ); let principal = PrincipalId::new("alice").unwrap(); @@ -294,6 +304,7 @@ async fn public_router_builders_accept_live_capability_probe() { assert_eq!(resp.status(), StatusCode::OK); let bytes = to_bytes(resp.into_body(), 4096).await.unwrap(); let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert!(probe_called.load(Ordering::SeqCst)); assert_eq!(body["entries"][0]["principal"], "bob"); assert_eq!(body["entries"][0]["method"], "BobVisibleThroughProbe"); } From 2207e03b26f9848b7bd46e663b580a5383c41dfc Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 23:09:36 +0400 Subject: [PATCH 17/20] fix: harden workspace state path selection --- CHANGELOG.md | 3 +- crates/astrid-capsule-install/src/local.rs | 57 ++- crates/astrid-capsule-install/src/meta.rs | 32 +- crates/astrid-capsule-install/src/paths.rs | 31 +- crates/astrid-capsule/src/discovery.rs | 16 +- crates/astrid-cli/src/commands/daemon.rs | 20 +- crates/astrid-cli/src/commands/wit.rs | 6 +- crates/astrid-config/src/loader.rs | 66 +++- crates/astrid-core/src/dirs.rs | 331 +++++++++++++++++- crates/astrid-core/src/dirs_tests.rs | 115 ++++++ .../src/routes/capsule_sources.rs | 12 +- crates/astrid-hooks/src/discovery.rs | 22 +- crates/astrid-kernel/src/lib.rs | 38 +- crates/astrid-kernel/src/socket.rs | 6 +- 14 files changed, 712 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 301cd6bd2..09cff9a73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,7 +91,8 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. relative directory name through `--workspace-state-dir` or `ASTRID_WORKSPACE_STATE_DIR`. Config, capsule installation and discovery, kernel boot, gateway source checks, hooks, and WIT garbage collection share - the selected layout and never scan both project roots. CLI uplinks, including + the selected layout, reject symlink/reparse redirection of workspace state and + its security-sensitive descendants, and never scan both project roots. CLI uplinks, including long-lived MCP reconnects, and project-sensitive management reads reject a daemon booted for a different project or layout; daemon stop remains available as a recovery operation. diff --git a/crates/astrid-capsule-install/src/local.rs b/crates/astrid-capsule-install/src/local.rs index 6b2dc7b9b..daac3f802 100644 --- a/crates/astrid-capsule-install/src/local.rs +++ b/crates/astrid-capsule-install/src/local.rs @@ -325,6 +325,19 @@ fn install_from_local_path_internal( workspace: InstallWorkspace<'_>, expected: Option>, ) -> anyhow::Result { + let checked_workspace = if options.workspace { + let root = workspace + .root + .context("workspace install requires a workspace root")?; + Some( + workspace + .layout + .resolve(root) + .context("selected workspace state path is unsafe")?, + ) + } else { + None + }; let manifest_path = source_dir.join("Capsule.toml"); if !manifest_path.exists() { bail!("No Capsule.toml found in {}", source_dir.display()); @@ -368,8 +381,17 @@ fn install_from_local_path_internal( workspace.layout, )?; 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()))?; + if let Some(selection) = &checked_workspace { + selection + .ensure_directory("capsules") + .context("failed to create checked workspace capsule directory")?; + selection + .resolve_directory(Path::new("capsules").join(id.as_str())) + .context("workspace capsule target changed after selection")?; + } else { + std::fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } // Phase detection from existing meta (read-only). let existing_meta = read_meta(&target_dir); @@ -391,6 +413,17 @@ fn install_from_local_path_internal( // this point onward must restore the backup over target_dir. let backup_dir = if target_dir.exists() { let backup = target_dir.with_extension("bak"); + if let Some(selection) = &checked_workspace { + selection + .verify() + .context("workspace changed before install backup")?; + let backup_name = backup + .file_name() + .context("workspace capsule backup has no file name")?; + selection + .resolve_directory(Path::new("capsules").join(backup_name)) + .context("workspace capsule backup path is unsafe")?; + } if backup.exists() { std::fs::remove_dir_all(&backup) .with_context(|| format!("failed to remove stale backup {}", backup.display()))?; @@ -407,6 +440,12 @@ fn install_from_local_path_internal( None }; + if let Some(selection) = &checked_workspace { + selection + .verify() + .context("workspace changed before capsule copy")?; + } + // Copy non-WASM tree to target. Excludes `*.wasm` and `wit/`. if let Err(e) = copy_capsule_dir(source_dir, &target_dir) { rollback(&target_dir, backup_dir.as_deref()); @@ -458,6 +497,14 @@ fn install_from_local_path_internal( rollback(&target_dir, backup_dir.as_deref()); return Err(e); } + if let Some(selection) = &checked_workspace { + selection + .resolve_directory(Path::new("capsules").join(id.as_str())) + .context("workspace capsule target changed during install")?; + selection + .verify() + .context("workspace changed during capsule install")?; + } // Mirror the capsule's WIT into the principal's `home://wit/` so the // system capsule's `list_interfaces` / `read_interface` tools can @@ -509,6 +556,12 @@ fn install_from_local_path_internal( tracing::warn!(path = %backup.display(), error = %e, "failed to remove install backup"); } + if let Some(selection) = &checked_workspace { + selection + .verify() + .context("workspace changed before install completion")?; + } + Ok(InstallOutput { target_dir, phase, diff --git a/crates/astrid-capsule-install/src/meta.rs b/crates/astrid-capsule-install/src/meta.rs index 6c1936fe6..b34eaccae 100644 --- a/crates/astrid-capsule-install/src/meta.rs +++ b/crates/astrid-capsule-install/src/meta.rs @@ -202,9 +202,17 @@ pub fn scan_installed_capsules_in_home_for_in_workspace( } if let Some(workspace_root) = workspace_root { - let ws_dir = workspace_layout.capsules_dir(workspace_root); + let workspace = workspace_layout + .resolve(workspace_root) + .context("selected workspace state path is unsafe")?; + let ws_dir = workspace + .capsules_dir() + .context("workspace capsule directory is unsafe")?; if ws_dir.is_dir() { scan_dir(&ws_dir, CapsuleLocation::Workspace, &mut capsules)?; + workspace + .verify() + .context("workspace selection changed while scanning capsules")?; } } @@ -362,4 +370,26 @@ mod tests { assert_eq!(capsules.len(), 1); assert_eq!(capsules[0].name, "alternate-capsule"); } + + #[cfg(unix)] + #[test] + fn scan_rejects_redirected_workspace_capsules() { + use std::os::unix::fs::symlink; + + let home_dir = tempfile::tempdir().unwrap(); + let home = AstridHome::from_path(home_dir.path()); + let workspace = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let state = workspace.path().join(".alternate-runtime"); + std::fs::create_dir(&state).unwrap(); + symlink(outside.path(), state.join("capsules")).unwrap(); + + let result = scan_installed_capsules_in_home_for_in_workspace( + &home, + &crate::paths::install_principal(), + Some(workspace.path()), + &WorkspaceLayout::new(".alternate-runtime").unwrap(), + ); + assert!(result.is_err()); + } } diff --git a/crates/astrid-capsule-install/src/paths.rs b/crates/astrid-capsule-install/src/paths.rs index e4ebcdfd5..bc09bae56 100644 --- a/crates/astrid-capsule-install/src/paths.rs +++ b/crates/astrid-capsule-install/src/paths.rs @@ -82,7 +82,12 @@ pub fn resolve_target_dir_for_in_workspace( ) -> anyhow::Result { if workspace { let root = workspace_root.context("workspace install requires a workspace root")?; - Ok(workspace_layout.capsules_dir(root).join(id)) + let selection = workspace_layout + .resolve(root) + .context("selected workspace state path is unsafe")?; + selection + .resolve_directory(Path::new("capsules").join(id)) + .context("workspace capsule target is unsafe") } else { let ph = home.principal_home(principal); Ok(ph.capsules_dir().join(id)) @@ -151,4 +156,28 @@ mod tests { PathBuf::from("/workspace/.alternate-runtime/capsules/example") ); } + + #[cfg(unix)] + #[test] + fn workspace_target_rejects_redirected_capsule_tree() { + use std::os::unix::fs::symlink; + + let home = tempfile::tempdir().unwrap(); + let workspace = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let state = workspace.path().join(".alternate-runtime"); + std::fs::create_dir(&state).unwrap(); + symlink(outside.path(), state.join("capsules")).unwrap(); + + let error = resolve_target_dir_for_in_workspace( + &AstridHome::from_path(home.path()), + &install_principal(), + "example", + true, + Some(workspace.path()), + &WorkspaceLayout::new(".alternate-runtime").unwrap(), + ) + .unwrap_err(); + assert!(error.to_string().contains("unsafe")); + } } diff --git a/crates/astrid-capsule/src/discovery.rs b/crates/astrid-capsule/src/discovery.rs index a0026d3ed..5302aa6e2 100644 --- a/crates/astrid-capsule/src/discovery.rs +++ b/crates/astrid-capsule/src/discovery.rs @@ -160,7 +160,21 @@ pub fn discover_manifests_in_workspace( // 2. Workspace-level capsules (lowest priority). if let Some(workspace_root) = workspace_root { - load_dedup(&workspace_layout.capsules_dir(workspace_root), "workspace"); + match workspace_layout + .resolve(workspace_root) + .and_then(|selection| selection.capsules_dir().map(|dir| (selection, dir))) + { + Ok((selection, dir)) => { + load_dedup(&dir, "workspace"); + if let Err(error) = selection.verify() { + warn!(%error, "Workspace changed during capsule discovery; discarding results"); + manifests.retain(|(_, path)| !path.starts_with(&dir)); + } + }, + Err(error) => { + warn!(%error, "Unsafe workspace capsule path; skipping workspace capsules") + }, + } } info!(count = manifests.len(), "Discovered capsule manifests"); diff --git a/crates/astrid-cli/src/commands/daemon.rs b/crates/astrid-cli/src/commands/daemon.rs index a540302cb..81cbed432 100644 --- a/crates/astrid-cli/src/commands/daemon.rs +++ b/crates/astrid-cli/src/commands/daemon.rs @@ -188,7 +188,7 @@ async fn ensure_daemon_inner(label: &str, announce: bool) -> Result<()> { } pub(crate) async fn ensure_daemon_workspace_matches(workspace_root: Option<&Path>) -> Result<()> { - let expected = expected_workspace_fingerprint(workspace_root); + let expected = expected_workspace_fingerprint(workspace_root)?; let ready_path = socket_client::readiness_path(); for _ in 0..DAEMON_READY_ATTEMPTS { @@ -208,12 +208,16 @@ pub(crate) async fn ensure_daemon_workspace_matches(workspace_root: Option<&Path ) } -fn expected_workspace_fingerprint(workspace_root: Option<&Path>) -> String { +fn expected_workspace_fingerprint(workspace_root: Option<&Path>) -> Result { let root = workspace_root.map_or_else( || std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), Path::to_path_buf, ); - astrid_core::dirs::workspace_selection_fingerprint(&root, crate::workspace_layout::current()) + astrid_core::dirs::checked_workspace_selection_fingerprint( + &root, + crate::workspace_layout::current(), + ) + .context("selected workspace state path is unsafe") } fn validate_daemon_workspace_metadata(metadata: &str, expected: &str) -> Result<()> { @@ -700,18 +704,20 @@ mod tests { assert_ne!(explicit.path(), current); assert_eq!( - expected_workspace_fingerprint(Some(explicit.path())), - astrid_core::dirs::workspace_selection_fingerprint( + expected_workspace_fingerprint(Some(explicit.path())).unwrap(), + astrid_core::dirs::checked_workspace_selection_fingerprint( explicit.path(), crate::workspace_layout::current(), ) + .unwrap() ); assert_eq!( - expected_workspace_fingerprint(None), - astrid_core::dirs::workspace_selection_fingerprint( + expected_workspace_fingerprint(None).unwrap(), + astrid_core::dirs::checked_workspace_selection_fingerprint( ¤t, crate::workspace_layout::current(), ) + .unwrap() ); } diff --git a/crates/astrid-cli/src/commands/wit.rs b/crates/astrid-cli/src/commands/wit.rs index 5b671771d..e0afa5c0d 100644 --- a/crates/astrid-cli/src/commands/wit.rs +++ b/crates/astrid-cli/src/commands/wit.rs @@ -180,10 +180,14 @@ fn collect_marks_in_workspace( // Workspace-level capsules (if running from a workspace) if let Some(workspace_root) = workspace_root { - let ws_caps = workspace_layout.capsules_dir(workspace_root); + let workspace = workspace_layout + .resolve(workspace_root) + .with_context(|| format!("unsafe workspace at {}", workspace_root.display()))?; + let ws_caps = workspace.capsules_dir()?; if ws_caps.is_dir() { collect_from_capsules_dir(&ws_caps, &mut marks); } + workspace.verify()?; } Ok(marks) diff --git a/crates/astrid-config/src/loader.rs b/crates/astrid-config/src/loader.rs index 200f62cfd..698e31cf8 100644 --- a/crates/astrid-config/src/loader.rs +++ b/crates/astrid-config/src/loader.rs @@ -58,6 +58,7 @@ pub fn load( /// /// Returns a [`ConfigError`] if any config file is malformed, or if the /// final merged configuration fails validation. +#[allow(clippy::too_many_lines)] pub fn load_with_layout( workspace_root: Option<&Path>, astrid_home_override: Option<&Path>, @@ -123,8 +124,27 @@ pub fn load_with_layout( // for restriction enforcement. This ensures restrictions work even when // no user config file exists (the baseline includes defaults + system). if let Some(ws_root) = workspace_root { - let ws_path = workspace_layout.config_path(ws_root); - if let Some(mut overlay) = try_load_file(&ws_path)? { + let workspace = + workspace_layout + .resolve(ws_root) + .map_err(|source| ConfigError::ReadError { + path: workspace_layout.state_dir(ws_root).display().to_string(), + source, + })?; + let ws_path = workspace + .config_path() + .map_err(|source| ConfigError::ReadError { + path: workspace.state_dir().display().to_string(), + source, + })?; + let workspace_overlay = try_load_file(&ws_path)?; + workspace + .verify() + .map_err(|source| ConfigError::ReadError { + path: ws_path.display().to_string(), + source, + })?; + if let Some(mut overlay) = workspace_overlay { // Resolve ${VAR} references in workspace overlay with restricted // env vars (only ASTRID_* and ANTHROPIC_*). This prevents a // malicious workspace config from exfiltrating sensitive env vars. @@ -145,6 +165,12 @@ pub fn load_with_layout( loaded_files.push(ws_path.display().to_string()); info!(path = %ws_path.display(), "loaded workspace config"); + workspace + .verify() + .map_err(|source| ConfigError::ReadError { + path: workspace.state_dir().display().to_string(), + source, + })?; } } @@ -394,6 +420,42 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn workspace_config_rejects_redirected_state_directory() { + use std::os::unix::fs::symlink; + + let home = tempfile::tempdir().unwrap(); + let workspace = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + std::fs::write( + outside.path().join("config.toml"), + "[model]\nprovider = \"claude\"\n", + ) + .unwrap(); + symlink(outside.path(), workspace.path().join(".alternate-runtime")).unwrap(); + + let layout = WorkspaceLayout::new(".alternate-runtime").unwrap(); + assert!(load_with_layout(Some(workspace.path()), Some(home.path()), &layout).is_err()); + } + + #[cfg(unix)] + #[test] + fn workspace_config_rejects_redirected_config_file() { + use std::os::unix::fs::symlink; + + let home = tempfile::tempdir().unwrap(); + let workspace = tempfile::tempdir().unwrap(); + let state = workspace.path().join(".alternate-runtime"); + std::fs::create_dir(&state).unwrap(); + let outside = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(outside.path(), "[model]\nprovider = \"claude\"\n").unwrap(); + symlink(outside.path(), state.join("config.toml")).unwrap(); + + let layout = WorkspaceLayout::new(".alternate-runtime").unwrap(); + assert!(load_with_layout(Some(workspace.path()), Some(home.path()), &layout).is_err()); + } + #[test] fn test_load_file_nonexistent() { let result = load_file(Path::new("/nonexistent/config.toml")); diff --git a/crates/astrid-core/src/dirs.rs b/crates/astrid-core/src/dirs.rs index 3abc72e33..49260ce7a 100644 --- a/crates/astrid-core/src/dirs.rs +++ b/crates/astrid-core/src/dirs.rs @@ -77,6 +77,19 @@ pub struct WorkspaceLayout { state_dir_name: String, } +/// A checked project workspace selection. +/// +/// The project root is canonical and the selected state directory is either +/// absent or a real directory directly beneath that root. Symlinks, junctions, +/// and other redirects are rejected by requiring an existing directory to +/// canonicalize to the exact direct-child path selected here. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceSelection { + project_root: PathBuf, + state_dir: PathBuf, + layout: WorkspaceLayout, +} + impl WorkspaceLayout { /// Create a layout from one portable relative directory name. /// @@ -164,6 +177,281 @@ impl WorkspaceLayout { pub fn hooks_dir(&self, project_root: &Path) -> PathBuf { self.state_dir(project_root).join("hooks") } + + /// Resolve and validate this layout beneath `project_root`. + /// + /// The root must exist and be a directory. If the state directory exists, + /// it must be a real directory whose canonical path is exactly the selected + /// direct child of the canonical root. A missing state directory is valid; + /// callers that create it must use [`WorkspaceSelection::ensure_state_dir`] + /// so the boundary is checked again after creation. + /// + /// # Errors + /// + /// Returns an error when the root cannot be canonicalized, is not a + /// directory, or the selected state path is redirected or is not a + /// directory. + pub fn resolve(&self, project_root: &Path) -> io::Result { + WorkspaceSelection::resolve(project_root, self.clone()) + } +} + +impl WorkspaceSelection { + fn resolve(project_root: &Path, layout: WorkspaceLayout) -> io::Result { + let project_root = std::fs::canonicalize(project_root).map_err(|error| { + io::Error::new( + error.kind(), + format!( + "failed to resolve workspace root {}: {error}", + project_root.display() + ), + ) + })?; + if !std::fs::metadata(&project_root)?.is_dir() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "workspace root is not a directory: {}", + project_root.display() + ), + )); + } + + let state_dir = project_root.join(layout.state_dir_name()); + verify_state_dir_path(&project_root, &state_dir)?; + Ok(Self { + project_root, + state_dir, + layout, + }) + } + + /// Canonical project root used by every workspace filesystem consumer. + #[must_use] + pub fn project_root(&self) -> &Path { + &self.project_root + } + + /// Checked selected state directory. + #[must_use] + pub fn state_dir(&self) -> &Path { + &self.state_dir + } + + /// Selected workspace layout. + #[must_use] + pub fn layout(&self) -> &WorkspaceLayout { + &self.layout + } + + /// Checked capsule directory under selected project state. + /// + /// # Errors + /// + /// Returns an error if an existing path component is redirected or is not + /// a directory. + pub fn capsules_dir(&self) -> io::Result { + self.resolve_directory("capsules") + } + + /// Checked configuration path under selected project state. + /// + /// # Errors + /// + /// Returns an error if the existing file or one of its parents is + /// redirected or has the wrong type. + pub fn config_path(&self) -> io::Result { + self.resolve_file("config.toml") + } + + /// Checked hooks directory under selected project state. + /// + /// # Errors + /// + /// Returns an error if an existing path component is redirected or is not + /// a directory. + pub fn hooks_dir(&self) -> io::Result { + self.resolve_directory("hooks") + } + + /// Resolve a relative directory beneath selected project state without + /// following an existing redirect. + /// + /// Missing components are allowed so callers can validate before creation. + /// + /// # Errors + /// + /// Returns an error for non-normal relative components, redirects, or an + /// existing non-directory component. + pub fn resolve_directory(&self, relative: impl AsRef) -> io::Result { + self.resolve_descendant(relative.as_ref(), DescendantKind::Directory) + } + + /// Resolve a relative file beneath selected project state without following + /// an existing redirect. + /// + /// Missing components are allowed so callers can validate before creation. + /// + /// # Errors + /// + /// Returns an error for non-normal relative components, redirects, a + /// non-directory parent, or an existing non-file target. + pub fn resolve_file(&self, relative: impl AsRef) -> io::Result { + self.resolve_descendant(relative.as_ref(), DescendantKind::File) + } + + fn resolve_descendant(&self, relative: &Path, kind: DescendantKind) -> io::Result { + self.verify()?; + let components = relative.components().collect::>(); + if components.is_empty() + || components + .iter() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "workspace descendant must be a non-empty relative path without traversal", + )); + } + + let mut current = self.state_dir.clone(); + for (index, component) in components.iter().enumerate() { + let Component::Normal(component) = component else { + unreachable!("components validated above") + }; + current.push(component); + let metadata = match std::fs::symlink_metadata(¤t) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => continue, + Err(error) => return Err(error), + }; + let final_component = index == components.len().saturating_sub(1); + let expected_file = final_component && kind == DescendantKind::File; + if metadata.file_type().is_symlink() + || (expected_file && !metadata.is_file()) + || (!expected_file && !metadata.is_dir()) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "workspace path must not contain redirects or unexpected file types: {}", + current.display() + ), + )); + } + if std::fs::canonicalize(¤t)? != current { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "workspace path redirects from its selected target: {}", + current.display() + ), + )); + } + } + Ok(self.state_dir.join(relative)) + } + + /// Re-check that the selected state path has not been redirected. + /// + /// A missing state directory remains valid. This permits a checked + /// selection to be created before initialization while still rejecting a + /// later symlink or non-directory replacement. + /// + /// # Errors + /// + /// Returns an error if the project root or state path no longer satisfies + /// the original selection. + pub fn verify(&self) -> io::Result<()> { + let current = Self::resolve(&self.project_root, self.layout.clone())?; + if current.project_root != self.project_root || current.state_dir != self.state_dir { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "workspace selection changed after validation", + )); + } + Ok(()) + } + + /// Create the selected state directory and verify it again afterwards. + /// + /// # Errors + /// + /// Returns an error if creation fails or the path is redirected before or + /// after creation. + pub fn ensure_state_dir(&self) -> io::Result<()> { + self.verify()?; + match std::fs::create_dir(&self.state_dir) { + Ok(()) => {}, + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}, + Err(error) => return Err(error), + } + self.verify() + } + + /// Create a checked relative directory and verify the full path afterwards. + /// + /// # Errors + /// + /// Returns an error if a component is unsafe, creation fails, or a redirect + /// appears during creation. + pub fn ensure_directory(&self, relative: impl AsRef) -> io::Result { + let relative = relative.as_ref(); + let path = self.resolve_directory(relative)?; + self.ensure_state_dir()?; + std::fs::create_dir_all(&path)?; + let checked = self.resolve_directory(relative)?; + self.verify()?; + Ok(checked) + } + + /// Stable opaque identity for the checked root and state-directory target. + #[must_use] + pub fn fingerprint(&self) -> String { + let mut hasher = Hasher::new(); + hasher.update(b"astrid-workspace-selection-v2\0"); + hash_path(&mut hasher, &self.project_root); + hasher.update(b"\0"); + hash_path(&mut hasher, &self.state_dir); + hasher.update(b"\0"); + hasher.update(self.layout.state_dir_name().as_bytes()); + hex::encode(hasher.finalize().as_bytes()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DescendantKind { + Directory, + File, +} + +fn verify_state_dir_path(project_root: &Path, state_dir: &Path) -> io::Result<()> { + let metadata = match std::fs::symlink_metadata(state_dir) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error), + }; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "workspace state path must be a real directory, not a redirect or file: {}", + state_dir.display() + ), + )); + } + + let canonical = std::fs::canonicalize(state_dir)?; + if canonical != state_dir || canonical.parent() != Some(project_root) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "workspace state directory escapes or redirects outside its selected path: {}", + state_dir.display() + ), + )); + } + Ok(()) } /// Stable identity for one project root and workspace layout selection. @@ -175,6 +463,9 @@ pub fn workspace_selection_fingerprint( project_root: &Path, workspace_layout: &WorkspaceLayout, ) -> String { + if let Ok(selection) = workspace_layout.resolve(project_root) { + return selection.fingerprint(); + } let root = std::fs::canonicalize(project_root).unwrap_or_else(|_| { if project_root.is_absolute() { project_root.to_path_buf() @@ -191,6 +482,21 @@ pub fn workspace_selection_fingerprint( hex::encode(hasher.finalize().as_bytes()) } +/// Resolve a workspace safely and derive its opaque selection identity. +/// +/// Security-sensitive callers must use this fallible form rather than relying +/// on a lexical path fingerprint. +/// +/// # Errors +/// +/// Returns an error if the workspace root or selected state path is unsafe. +pub fn checked_workspace_selection_fingerprint( + project_root: &Path, + workspace_layout: &WorkspaceLayout, +) -> io::Result { + Ok(workspace_layout.resolve(project_root)?.fingerprint()) +} + fn hash_path(hasher: &mut Hasher, path: &Path) { #[cfg(unix)] { @@ -338,7 +644,7 @@ impl AstridHome { /// Ensure the system directory structure exists with secure permissions. /// - /// Creates `etc/`, `var/`, `run/`, `log/`, `keys/`, `lib/`, and `home/`. + /// Creates `etc/`, `var/`, `run/`, `log/`, `keys/`, `secrets/`, `lib/`, and `home/`. /// Writes `etc/layout-version` with the current version. /// Sets all directories to `0o700` on Unix. /// @@ -357,6 +663,7 @@ impl AstridHome { self.run_dir(), self.log_dir(), self.keys_dir(), + self.secrets_dir(), self.bin_dir(), self.wit_dir(), self.wit_store_dir(), @@ -810,11 +1117,22 @@ impl WorkspaceDir { /// /// Returns an error if directory creation or workspace ID generation fails. pub fn ensure(&self) -> io::Result<()> { - std::fs::create_dir_all(self.dot_astrid())?; + let selection = self.layout.resolve(&self.project_root)?; + selection.ensure_state_dir()?; let _ = self.workspace_id()?; + selection.verify()?; Ok(()) } + /// Resolve this workspace through the checked filesystem boundary. + /// + /// # Errors + /// + /// Returns an error if the project root or selected state path is unsafe. + pub fn selection(&self) -> io::Result { + self.layout.resolve(&self.project_root) + } + /// Project root directory containing the selected state directory. #[must_use] pub fn root(&self) -> &Path { @@ -860,16 +1178,21 @@ impl WorkspaceDir { /// /// Returns an error if the file cannot be read or written. pub fn workspace_id(&self) -> io::Result { - let path = self.workspace_id_path(); + let selection = self.selection()?; + selection.ensure_state_dir()?; + let path = selection.resolve_file("workspace-id")?; if let Ok(content) = std::fs::read_to_string(&path) { let trimmed = content.trim(); if let Ok(id) = Uuid::parse_str(trimmed) { + selection.verify()?; return Ok(id); } } - std::fs::create_dir_all(self.dot_astrid())?; let id = Uuid::new_v4(); + selection.verify()?; std::fs::write(&path, id.to_string())?; + selection.resolve_file("workspace-id")?; + selection.verify()?; Ok(id) } diff --git a/crates/astrid-core/src/dirs_tests.rs b/crates/astrid-core/src/dirs_tests.rs index c12294907..34268ec33 100644 --- a/crates/astrid-core/src/dirs_tests.rs +++ b/crates/astrid-core/src/dirs_tests.rs @@ -79,6 +79,7 @@ fn test_astrid_home_ensure_creates_dirs() { assert!(home.run_dir().exists()); assert!(home.log_dir().exists()); assert!(home.keys_dir().exists()); + assert!(home.secrets_dir().exists()); assert!(home.bin_dir().exists()); assert!(home.home_dir().exists()); } @@ -119,6 +120,26 @@ fn test_astrid_home_ensure_sets_permissions() { assert_eq!(keys_perms.mode() & 0o777, 0o700); } +#[cfg(unix)] +#[test] +fn test_astrid_home_ensure_repairs_secrets_permissions_without_touching_contents() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let home = AstridHome::from_path(dir.path()); + std::fs::create_dir_all(home.secrets_dir()).unwrap(); + let secret = home.secrets_dir().join("existing-secret"); + let bytes = b"preserve-these-secret-bytes"; + std::fs::write(&secret, bytes).unwrap(); + std::fs::set_permissions(home.secrets_dir(), std::fs::Permissions::from_mode(0o755)).unwrap(); + + home.ensure().unwrap(); + + let permissions = std::fs::metadata(home.secrets_dir()).unwrap().permissions(); + assert_eq!(permissions.mode() & 0o777, 0o700); + assert_eq!(std::fs::read(secret).unwrap(), bytes); +} + // ── AstridHome path accessors ──────────────────────────────────── #[test] @@ -316,6 +337,100 @@ fn workspace_selection_identity_covers_root_and_layout() { ); } +#[test] +fn workspace_selection_accepts_missing_then_real_state_directory() { + let root = tempfile::tempdir().unwrap(); + let layout = WorkspaceLayout::new(".alternate-runtime").unwrap(); + let selection = layout.resolve(root.path()).unwrap(); + + assert!(!selection.state_dir().exists()); + selection.ensure_state_dir().unwrap(); + selection.verify().unwrap(); + assert!(selection.state_dir().is_dir()); + assert_eq!( + selection.project_root(), + root.path().canonicalize().unwrap() + ); +} + +#[cfg(unix)] +#[test] +fn workspace_selection_rejects_state_directory_symlink_escape() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + symlink(outside.path(), root.path().join(".alternate-runtime")).unwrap(); + + let error = WorkspaceLayout::new(".alternate-runtime") + .unwrap() + .resolve(root.path()) + .unwrap_err(); + assert!(error.to_string().contains("redirect")); +} + +#[cfg(unix)] +#[test] +fn workspace_selection_detects_post_selection_symlink_swap() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let layout = WorkspaceLayout::new(".alternate-runtime").unwrap(); + let selection = layout.resolve(root.path()).unwrap(); + selection.ensure_state_dir().unwrap(); + + std::fs::remove_dir(selection.state_dir()).unwrap(); + symlink(outside.path(), selection.state_dir()).unwrap(); + + assert!(selection.verify().is_err()); + assert!(checked_workspace_selection_fingerprint(root.path(), &layout).is_err()); +} + +#[cfg(unix)] +#[test] +fn workspace_selection_rejects_redirected_capsule_directory() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let selection = WorkspaceLayout::default().resolve(root.path()).unwrap(); + selection.ensure_state_dir().unwrap(); + symlink(outside.path(), selection.state_dir().join("capsules")).unwrap(); + + assert!(selection.capsules_dir().is_err()); + assert!(selection.resolve_directory("capsules/example").is_err()); +} + +#[cfg(unix)] +#[test] +fn workspace_selection_rejects_redirected_config_file() { + use std::os::unix::fs::symlink; + + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::NamedTempFile::new().unwrap(); + let selection = WorkspaceLayout::default().resolve(root.path()).unwrap(); + selection.ensure_state_dir().unwrap(); + symlink(outside.path(), selection.state_dir().join("config.toml")).unwrap(); + + assert!(selection.config_path().is_err()); +} + +#[test] +fn checked_workspace_fingerprint_binds_state_directory_target() { + let root = tempfile::tempdir().unwrap(); + let default = WorkspaceLayout::default(); + let alternate = WorkspaceLayout::new(".alternate-runtime").unwrap(); + + let default_fingerprint = + checked_workspace_selection_fingerprint(root.path(), &default).unwrap(); + let alternate_fingerprint = + checked_workspace_selection_fingerprint(root.path(), &alternate).unwrap(); + + assert_ne!(default_fingerprint, alternate_fingerprint); + assert_eq!(default_fingerprint.len(), 64); +} + #[test] fn workspace_detect_uses_only_the_selected_state_directory() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/astrid-gateway/src/routes/capsule_sources.rs b/crates/astrid-gateway/src/routes/capsule_sources.rs index 027bd7e43..5be7e1150 100644 --- a/crates/astrid-gateway/src/routes/capsule_sources.rs +++ b/crates/astrid-gateway/src/routes/capsule_sources.rs @@ -41,12 +41,12 @@ pub(super) fn trusted_capsule_source_ids( // principal may have its own installed version); it no longer contributes to // the derived source id, which is content-addressed and shared across // principals (issue #1069). - let dirs = vec![ - home.principal_home(caller).capsules_dir().join(capsule_id), - workspace_layout - .capsules_dir(workspace_root) - .join(capsule_id), - ]; + let mut dirs = vec![home.principal_home(caller).capsules_dir().join(capsule_id)]; + if let Ok(workspace) = workspace_layout.resolve(workspace_root) + && let Ok(capsules) = workspace.capsules_dir() + { + dirs.push(capsules.join(capsule_id)); + } trusted_capsule_source_ids_from_dirs(capsule_id, dirs) } diff --git a/crates/astrid-hooks/src/discovery.rs b/crates/astrid-hooks/src/discovery.rs index 2bc5d203d..7493098c9 100644 --- a/crates/astrid-hooks/src/discovery.rs +++ b/crates/astrid-hooks/src/discovery.rs @@ -73,13 +73,23 @@ pub(crate) fn discover_hooks_in_workspace( let mut hooks = Vec::new(); if let Some(workspace_root) = workspace_root { - let local_hooks_dir = workspace_layout.hooks_dir(workspace_root); - if local_hooks_dir.exists() { - info!(path = %local_hooks_dir.display(), "Discovering hooks from local directory"); - match load_hooks_from_dir(&local_hooks_dir) { - Ok(found) => hooks.extend(found), - Err(e) => warn!(error = %e, "Failed to load hooks from local directory"), + let checked = workspace_layout + .resolve(workspace_root) + .and_then(|selection| selection.hooks_dir().map(|dir| (selection, dir))); + if let Ok((selection, local_hooks_dir)) = checked { + if local_hooks_dir.exists() { + info!(path = %local_hooks_dir.display(), "Discovering hooks from local directory"); + match load_hooks_from_dir(&local_hooks_dir) { + Ok(found) => hooks.extend(found), + Err(e) => warn!(error = %e, "Failed to load hooks from local directory"), + } + } + if let Err(error) = selection.verify() { + warn!(%error, "Workspace changed during hook discovery; discarding workspace hooks"); + hooks.clear(); } + } else if let Err(error) = checked { + warn!(%error, "Unsafe workspace hook path; skipping workspace hooks"); } } diff --git a/crates/astrid-kernel/src/lib.rs b/crates/astrid-kernel/src/lib.rs index de0995059..2671192c2 100644 --- a/crates/astrid-kernel/src/lib.rs +++ b/crates/astrid-kernel/src/lib.rs @@ -55,7 +55,7 @@ use astrid_capsule::profile_cache::PrincipalProfileCache; use astrid_capsule::registry::CapsuleRegistry; use astrid_capsule_types::CapsuleId; use astrid_core::SessionId; -use astrid_core::dirs::WorkspaceLayout; +use astrid_core::dirs::{WorkspaceLayout, WorkspaceSelection}; use astrid_core::groups::GroupConfig; use astrid_core::principal::PrincipalId; use astrid_crypto::KeyPair; @@ -117,6 +117,8 @@ pub struct Kernel { pub workspace_root: PathBuf, /// Per-project runtime state layout selected at boot. workspace_layout: WorkspaceLayout, + /// Checked root/state target used to detect later filesystem redirection. + workspace_selection: WorkspaceSelection, /// The principal home resources directory (`~/.astrid/home/{principal}/`). /// Capsules declaring `fs_read = ["home://"]` can read files under this /// root. Scoped to the principal's home so that keys, databases, and @@ -344,6 +346,12 @@ impl Kernel { &self.workspace_layout } + /// Checked project state selection captured at boot. + #[must_use] + pub fn workspace_selection(&self) -> &WorkspaceSelection { + &self.workspace_selection + } + /// Boot a new Kernel instance mounted at the specified directory. /// /// The native composition root: resolves the Astrid home, opens the @@ -587,6 +595,11 @@ impl Kernel { singleton_lock, } = resources; + let workspace_selection = workspace_layout.resolve(&workspace_root).map_err(|error| { + std::io::Error::new(error.kind(), format!("unsafe workspace selection: {error}")) + })?; + let workspace_root = workspace_selection.project_root().to_path_buf(); + let event_bus = Arc::new(EventBus::new()); let capsules = Arc::new(RwLock::new(CapsuleRegistry::new())); @@ -706,6 +719,7 @@ impl Kernel { vfs_root_handle: root_handle, workspace_root, workspace_layout, + workspace_selection, home_root, cli_socket_listener, singleton_lock, @@ -2252,6 +2266,9 @@ pub(crate) async fn test_kernel_with_home(home: astrid_core::dirs::AstridHome) - vfs_root_handle: root_handle, workspace_root: home.root().to_path_buf(), workspace_layout: WorkspaceLayout::default(), + workspace_selection: WorkspaceLayout::default() + .resolve(home.root()) + .expect("test workspace selection"), home_root: Some(principal_home.root().to_path_buf()), cli_socket_listener: None, singleton_lock: None, @@ -2952,14 +2969,17 @@ fn capsule_discovery_paths_for( principal: &PrincipalId, workspace_layout: &WorkspaceLayout, ) -> Vec { - let workspace = astrid_core::dirs::WorkspaceDir::from_path_with_layout( - workspace_root, - workspace_layout.clone(), - ); - vec![ - home.principal_home(principal).capsules_dir(), - workspace.capsules_dir(), - ] + let mut paths = vec![home.principal_home(principal).capsules_dir()]; + match workspace_layout + .resolve(workspace_root) + .and_then(|workspace| workspace.capsules_dir()) + { + Ok(path) => paths.push(path), + Err(error) => { + tracing::warn!(%error, "Unsafe workspace capsule path; skipping workspace capsules"); + }, + } + paths } #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] diff --git a/crates/astrid-kernel/src/socket.rs b/crates/astrid-kernel/src/socket.rs index 7a6bac474..f21bfe370 100644 --- a/crates/astrid-kernel/src/socket.rs +++ b/crates/astrid-kernel/src/socket.rs @@ -280,8 +280,10 @@ pub fn write_readiness_file_for_workspace( workspace_layout: &astrid_core::dirs::WorkspaceLayout, ) -> Result<(), std::io::Error> { let path = readiness_path(); - let fingerprint = - astrid_core::dirs::workspace_selection_fingerprint(workspace_root, workspace_layout); + let fingerprint = astrid_core::dirs::checked_workspace_selection_fingerprint( + workspace_root, + workspace_layout, + )?; publish_readiness_metadata(&path, &format!("v1:{fingerprint}\n")) } From 73a8ea0635371134453223f15e3fa48370e710b6 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 23:34:09 +0400 Subject: [PATCH 18/20] fix: reject redirected workspace descendants --- CHANGELOG.md | 7 +- crates/astrid-capsule-install/src/local.rs | 16 +++- crates/astrid-capsule-install/src/meta.rs | 30 ++++++- crates/astrid-capsule-install/src/paths.rs | 9 +- crates/astrid-capsule/src/discovery.rs | 32 ++++++- crates/astrid-cli/src/commands/wit.rs | 29 ++++++- crates/astrid-core/src/dirs.rs | 40 +++++++++ crates/astrid-core/src/dirs_tests.rs | 35 ++++++++ .../src/routes/capsule_sources.rs | 52 ++++++++++- crates/astrid-hooks/src/discovery.rs | 23 ++++- crates/astrid-kernel/src/kernel_router/mod.rs | 3 +- crates/astrid-kernel/src/lib.rs | 87 ++++++++++++------- 12 files changed, 311 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09cff9a73..d22835894 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,9 +91,10 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. relative directory name through `--workspace-state-dir` or `ASTRID_WORKSPACE_STATE_DIR`. Config, capsule installation and discovery, kernel boot, gateway source checks, hooks, and WIT garbage collection share - the selected layout, reject symlink/reparse redirection of workspace state and - its security-sensitive descendants, and never scan both project roots. CLI uplinks, including - long-lived MCP reconnects, and project-sensitive management reads reject a + the selected layout and reject persistent symlink/reparse redirection anywhere + in workspace capsule and hook trees before reading them. They never scan both + project roots. CLI uplinks, including long-lived MCP reconnects, and + project-sensitive management reads reject a daemon booted for a different project or layout; daemon stop remains available as a recovery operation. diff --git a/crates/astrid-capsule-install/src/local.rs b/crates/astrid-capsule-install/src/local.rs index daac3f802..b4ccdd178 100644 --- a/crates/astrid-capsule-install/src/local.rs +++ b/crates/astrid-capsule-install/src/local.rs @@ -388,6 +388,9 @@ fn install_from_local_path_internal( selection .resolve_directory(Path::new("capsules").join(id.as_str())) .context("workspace capsule target changed after selection")?; + selection + .verify_tree("capsules") + .context("workspace capsule tree contains an unsafe redirect")?; } else { std::fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; @@ -454,6 +457,11 @@ fn install_from_local_path_internal( // Preserve existing .env.json (user configuration survives reinstall). if let Some(ref backup) = backup_dir { + if let Some(selection) = &checked_workspace { + selection + .verify_tree("capsules") + .context("workspace backup contains an unsafe redirect")?; + } restore_env_from_backup_for(home, target_principal, backup, id.as_str()); } @@ -502,8 +510,8 @@ fn install_from_local_path_internal( .resolve_directory(Path::new("capsules").join(id.as_str())) .context("workspace capsule target changed during install")?; selection - .verify() - .context("workspace changed during capsule install")?; + .verify_tree("capsules") + .context("workspace capsule tree changed during install")?; } // Mirror the capsule's WIT into the principal's `home://wit/` so the @@ -558,8 +566,8 @@ fn install_from_local_path_internal( if let Some(selection) = &checked_workspace { selection - .verify() - .context("workspace changed before install completion")?; + .verify_tree("capsules") + .context("workspace capsule tree changed before install completion")?; } Ok(InstallOutput { diff --git a/crates/astrid-capsule-install/src/meta.rs b/crates/astrid-capsule-install/src/meta.rs index b34eaccae..f11bcd020 100644 --- a/crates/astrid-capsule-install/src/meta.rs +++ b/crates/astrid-capsule-install/src/meta.rs @@ -206,12 +206,12 @@ pub fn scan_installed_capsules_in_home_for_in_workspace( .resolve(workspace_root) .context("selected workspace state path is unsafe")?; let ws_dir = workspace - .capsules_dir() + .verify_tree("capsules") .context("workspace capsule directory is unsafe")?; if ws_dir.is_dir() { scan_dir(&ws_dir, CapsuleLocation::Workspace, &mut capsules)?; workspace - .verify() + .verify_tree("capsules") .context("workspace selection changed while scanning capsules")?; } } @@ -392,4 +392,30 @@ mod tests { ); assert!(result.is_err()); } + + #[cfg(unix)] + #[test] + fn scan_rejects_symlinked_workspace_capsule_metadata() { + use std::os::unix::fs::symlink; + + let home_dir = tempfile::tempdir().unwrap(); + let home = AstridHome::from_path(home_dir.path()); + let workspace = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let capsule = workspace.path().join(".astrid/capsules/example"); + std::fs::create_dir_all(&capsule).unwrap(); + let outside_meta = outside.path().join("meta.json"); + std::fs::write(&outside_meta, "{}").unwrap(); + symlink(outside_meta, capsule.join("meta.json")).unwrap(); + + assert!( + scan_installed_capsules_in_home_for_in_workspace( + &home, + &crate::paths::install_principal(), + Some(workspace.path()), + &WorkspaceLayout::default(), + ) + .is_err() + ); + } } diff --git a/crates/astrid-capsule-install/src/paths.rs b/crates/astrid-capsule-install/src/paths.rs index bc09bae56..b61b5b61d 100644 --- a/crates/astrid-capsule-install/src/paths.rs +++ b/crates/astrid-capsule-install/src/paths.rs @@ -142,18 +142,21 @@ mod tests { #[test] fn workspace_target_uses_injected_layout() { let layout = WorkspaceLayout::new(".alternate-runtime").unwrap(); - let root = Path::new("/workspace"); + let root = tempfile::tempdir().unwrap(); assert_eq!( resolve_target_dir_for_in_workspace( &AstridHome::from_path("/home/runtime"), &install_principal(), "example", true, - Some(root), + Some(root.path()), &layout, ) .unwrap(), - PathBuf::from("/workspace/.alternate-runtime/capsules/example") + root.path() + .canonicalize() + .unwrap() + .join(".alternate-runtime/capsules/example") ); } diff --git a/crates/astrid-capsule/src/discovery.rs b/crates/astrid-capsule/src/discovery.rs index 5302aa6e2..582ad100b 100644 --- a/crates/astrid-capsule/src/discovery.rs +++ b/crates/astrid-capsule/src/discovery.rs @@ -162,11 +162,14 @@ pub fn discover_manifests_in_workspace( if let Some(workspace_root) = workspace_root { match workspace_layout .resolve(workspace_root) - .and_then(|selection| selection.capsules_dir().map(|dir| (selection, dir))) - { + .and_then(|selection| { + selection + .verify_tree("capsules") + .map(|dir| (selection, dir)) + }) { Ok((selection, dir)) => { load_dedup(&dir, "workspace"); - if let Err(error) = selection.verify() { + if let Err(error) = selection.verify_tree("capsules") { warn!(%error, "Workspace changed during capsule discovery; discarding results"); manifests.retain(|(_, path)| !path.starts_with(&dir)); } @@ -429,6 +432,29 @@ version = "0.1.0" assert_eq!(found[0].0.package.name, "alternate-capsule"); } + #[cfg(unix)] + #[test] + fn discovery_skips_workspace_with_symlinked_manifest() { + use std::os::unix::fs::symlink; + + let workspace = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let capsule = workspace.path().join(".astrid/capsules/redirected"); + std::fs::create_dir_all(&capsule).unwrap(); + let manifest = outside.path().join("Capsule.toml"); + std::fs::write(&manifest, "[package]\nname='outside'\nversion='1.0.0'\n").unwrap(); + symlink(manifest, capsule.join("Capsule.toml")).unwrap(); + + assert!( + discover_manifests_in_workspace( + None, + Some(workspace.path()), + &WorkspaceLayout::default() + ) + .is_empty() + ); + } + #[test] fn load_manifest_accepts_valid_ipc_publish() { let toml = format!( diff --git a/crates/astrid-cli/src/commands/wit.rs b/crates/astrid-cli/src/commands/wit.rs index e0afa5c0d..6a6c0f319 100644 --- a/crates/astrid-cli/src/commands/wit.rs +++ b/crates/astrid-cli/src/commands/wit.rs @@ -183,11 +183,11 @@ fn collect_marks_in_workspace( let workspace = workspace_layout .resolve(workspace_root) .with_context(|| format!("unsafe workspace at {}", workspace_root.display()))?; - let ws_caps = workspace.capsules_dir()?; + let ws_caps = workspace.verify_tree("capsules")?; if ws_caps.is_dir() { collect_from_capsules_dir(&ws_caps, &mut marks); } - workspace.verify()?; + workspace.verify_tree("capsules")?; } Ok(marks) @@ -250,4 +250,29 @@ mod tests { assert!(marks.contains("alternate-hash")); assert!(!marks.contains("default-hash")); } + + #[cfg(unix)] + #[test] + fn wit_gc_rejects_symlinked_workspace_meta() { + use std::os::unix::fs::symlink; + + let home_dir = tempfile::tempdir().unwrap(); + let home = AstridHome::from_path(home_dir.path()); + let workspace = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let capsule = workspace.path().join(".astrid/capsules/example"); + std::fs::create_dir_all(&capsule).unwrap(); + let outside_meta = outside.path().join("meta.json"); + std::fs::write(&outside_meta, r#"{"wit_files":{"x":"outside-hash"}}"#).unwrap(); + symlink(outside_meta, capsule.join("meta.json")).unwrap(); + + assert!( + collect_marks_in_workspace( + &home, + Some(workspace.path()), + &astrid_core::dirs::WorkspaceLayout::default(), + ) + .is_err() + ); + } } diff --git a/crates/astrid-core/src/dirs.rs b/crates/astrid-core/src/dirs.rs index 49260ce7a..2b2d0f9ae 100644 --- a/crates/astrid-core/src/dirs.rs +++ b/crates/astrid-core/src/dirs.rs @@ -300,6 +300,46 @@ impl WorkspaceSelection { self.resolve_descendant(relative.as_ref(), DescendantKind::File) } + /// Verify every existing entry in a workspace-relative tree without + /// following redirects. + /// + /// # Errors + /// + /// Returns an error if the root is unsafe, any descendant is a symlink, + /// reparse redirect, or special file, or the tree changes while walking. + pub fn verify_tree(&self, relative: impl AsRef) -> io::Result { + let relative = relative.as_ref(); + let root = self.resolve_directory(relative)?; + if !root.exists() { + return Ok(root); + } + let mut pending = vec![root.clone()]; + while let Some(dir) = pending.pop() { + for entry in std::fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + let metadata = std::fs::symlink_metadata(&path)?; + if metadata.file_type().is_symlink() + || (!metadata.is_dir() && !metadata.is_file()) + || std::fs::canonicalize(&path)? != path + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "workspace tree contains a redirected or special entry: {}", + path.display() + ), + )); + } + if metadata.is_dir() { + pending.push(path); + } + } + } + self.resolve_directory(relative)?; + Ok(root) + } + fn resolve_descendant(&self, relative: &Path, kind: DescendantKind) -> io::Result { self.verify()?; let components = relative.components().collect::>(); diff --git a/crates/astrid-core/src/dirs_tests.rs b/crates/astrid-core/src/dirs_tests.rs index 34268ec33..f9ad0b18e 100644 --- a/crates/astrid-core/src/dirs_tests.rs +++ b/crates/astrid-core/src/dirs_tests.rs @@ -402,6 +402,41 @@ fn workspace_selection_rejects_redirected_capsule_directory() { assert!(selection.resolve_directory("capsules/example").is_err()); } +#[cfg(unix)] +#[test] +fn workspace_selection_rejects_persistent_redirects_anywhere_in_checked_trees() { + use std::os::unix::fs::symlink; + + for relative in [ + "capsules/child", + "capsules/child/Capsule.toml", + "capsules/child/meta.json", + "capsules/child/.env.json", + "capsules/child/component.wasm", + "hooks/direct.toml", + ] { + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let selection = WorkspaceLayout::default().resolve(root.path()).unwrap(); + selection.ensure_state_dir().unwrap(); + let redirected = selection.state_dir().join(relative); + std::fs::create_dir_all(redirected.parent().unwrap()).unwrap(); + if relative == "capsules/child" { + symlink(outside.path(), &redirected).unwrap(); + } else { + let outside_file = outside.path().join("outside"); + std::fs::write(&outside_file, b"outside bytes").unwrap(); + symlink(outside_file, &redirected).unwrap(); + } + let tree = if relative.starts_with("hooks/") { + "hooks" + } else { + "capsules" + }; + assert!(selection.verify_tree(tree).is_err(), "accepted {relative}"); + } +} + #[cfg(unix)] #[test] fn workspace_selection_rejects_redirected_config_file() { diff --git a/crates/astrid-gateway/src/routes/capsule_sources.rs b/crates/astrid-gateway/src/routes/capsule_sources.rs index 5be7e1150..c72c5b24d 100644 --- a/crates/astrid-gateway/src/routes/capsule_sources.rs +++ b/crates/astrid-gateway/src/routes/capsule_sources.rs @@ -36,19 +36,36 @@ pub(super) fn trusted_capsule_source_ids( let Ok(home) = AstridHome::resolve() else { return Vec::new(); }; + trusted_capsule_source_ids_in_home(capsule_id, caller, workspace_root, workspace_layout, &home) +} +fn trusted_capsule_source_ids_in_home( + capsule_id: &str, + caller: &PrincipalId, + workspace_root: &Path, + workspace_layout: &WorkspaceLayout, + home: &AstridHome, +) -> Vec { // The `caller` selects WHICH install set to read the content hash from (a // principal may have its own installed version); it no longer contributes to // the derived source id, which is content-addressed and shared across // principals (issue #1069). let mut dirs = vec![home.principal_home(caller).capsules_dir().join(capsule_id)]; - if let Ok(workspace) = workspace_layout.resolve(workspace_root) - && let Ok(capsules) = workspace.capsules_dir() + let workspace = workspace_layout.resolve(workspace_root).ok(); + if let Some(workspace) = &workspace + && let Ok(capsules) = workspace.verify_tree("capsules") { dirs.push(capsules.join(capsule_id)); } - trusted_capsule_source_ids_from_dirs(capsule_id, dirs) + let ids = trusted_capsule_source_ids_from_dirs(capsule_id, dirs); + if workspace + .as_ref() + .is_some_and(|workspace| workspace.verify_tree("capsules").is_err()) + { + return Vec::new(); + } + ids } fn trusted_capsule_source_ids_from_dirs( @@ -166,7 +183,10 @@ fn read_manifest_package(dir: &Path) -> Option { mod tests { use super::{ capsule_source_id_v1, synthetic_content_hash, trusted_capsule_source_ids_from_dirs, + trusted_capsule_source_ids_in_home, }; + use astrid_core::PrincipalId; + use astrid_core::dirs::{AstridHome, WorkspaceLayout}; use serde_json::json; use uuid::Uuid; @@ -304,4 +324,30 @@ mod tests { assert!(ids.is_empty()); } + + #[cfg(unix)] + #[test] + fn workspace_source_id_skips_symlinked_meta_and_manifest() { + use std::os::unix::fs::symlink; + + for name in ["meta.json", "Capsule.toml"] { + let workspace = tempfile::tempdir().unwrap(); + let home_dir = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let capsule = workspace.path().join(".astrid/capsules/example"); + std::fs::create_dir_all(&capsule).unwrap(); + let target = outside.path().join(name); + std::fs::write(&target, r#"{"wasm_hash":"outside"}"#).unwrap(); + symlink(target, capsule.join(name)).unwrap(); + + let ids = trusted_capsule_source_ids_in_home( + "example", + &PrincipalId::default(), + workspace.path(), + &WorkspaceLayout::default(), + &AstridHome::from_path(home_dir.path()), + ); + assert!(ids.is_empty(), "accepted redirected {name}"); + } + } } diff --git a/crates/astrid-hooks/src/discovery.rs b/crates/astrid-hooks/src/discovery.rs index 7493098c9..dfda1e9ce 100644 --- a/crates/astrid-hooks/src/discovery.rs +++ b/crates/astrid-hooks/src/discovery.rs @@ -75,7 +75,7 @@ pub(crate) fn discover_hooks_in_workspace( if let Some(workspace_root) = workspace_root { let checked = workspace_layout .resolve(workspace_root) - .and_then(|selection| selection.hooks_dir().map(|dir| (selection, dir))); + .and_then(|selection| selection.verify_tree("hooks").map(|dir| (selection, dir))); if let Ok((selection, local_hooks_dir)) = checked { if local_hooks_dir.exists() { info!(path = %local_hooks_dir.display(), "Discovering hooks from local directory"); @@ -84,7 +84,7 @@ pub(crate) fn discover_hooks_in_workspace( Err(e) => warn!(error = %e, "Failed to load hooks from local directory"), } } - if let Err(error) = selection.verify() { + if let Err(error) = selection.verify_tree("hooks") { warn!(%error, "Workspace changed during hook discovery; discarding workspace hooks"); hooks.clear(); } @@ -307,4 +307,23 @@ mod tests { assert_eq!(hooks.len(), 1); assert_eq!(hooks[0].name.as_deref(), Some("selected")); } + + #[cfg(unix)] + #[test] + fn discovery_skips_workspace_with_symlinked_hook_file() { + use std::os::unix::fs::symlink; + + let workspace = TempDir::new().unwrap(); + let outside = TempDir::new().unwrap(); + let hooks = workspace.path().join(".astrid/hooks"); + std::fs::create_dir_all(&hooks).unwrap(); + let target = outside.path().join("HOOK.toml"); + std::fs::write(&target, "event = 'session-start'\n").unwrap(); + symlink(target, hooks.join("HOOK.toml")).unwrap(); + + assert!( + discover_hooks_in_workspace(None, Some(workspace.path()), &WorkspaceLayout::default()) + .is_empty() + ); + } } diff --git a/crates/astrid-kernel/src/kernel_router/mod.rs b/crates/astrid-kernel/src/kernel_router/mod.rs index b134fc340..4a9bf982d 100644 --- a/crates/astrid-kernel/src/kernel_router/mod.rs +++ b/crates/astrid-kernel/src/kernel_router/mod.rs @@ -509,10 +509,11 @@ async fn inventory_manifest_map( &kernel.workspace_layout, ); let workspace_layout = kernel.workspace_layout.clone(); + let workspace_root = kernel.workspace_root.clone(); let discovered = match tokio::task::spawn_blocking(move || { astrid_capsule::discovery::discover_manifests_in_workspace( Some(&paths), - None, + Some(&workspace_root), &workspace_layout, ) }) diff --git a/crates/astrid-kernel/src/lib.rs b/crates/astrid-kernel/src/lib.rs index 2671192c2..c24774c8e 100644 --- a/crates/astrid-kernel/src/lib.rs +++ b/crates/astrid-kernel/src/lib.rs @@ -805,6 +805,46 @@ impl Kernel { Ok(kernel) } + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + fn verify_workspace_capsule_tree(&self, dir: &Path) -> anyhow::Result<()> { + if let Ok(relative) = dir.strip_prefix(self.workspace_selection.state_dir()) { + self.workspace_selection + .verify_tree(relative) + .map_err(|error| { + anyhow::anyhow!("workspace capsule tree contains an unsafe redirect: {error}") + })?; + } + Ok(()) + } + + #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] + fn verify_workspace_component_paths( + &self, + dir: &Path, + manifest: &astrid_capsule_types::manifest::CapsuleManifest, + ) -> anyhow::Result<()> { + let Ok(capsule_relative) = dir.strip_prefix(self.workspace_selection.state_dir()) else { + return Ok(()); + }; + for component in &manifest.components { + if component.path.is_absolute() { + anyhow::bail!( + "workspace capsule component must be relative: {}", + component.path.display() + ); + } + self.workspace_selection + .resolve_file(capsule_relative.join(&component.path)) + .map_err(|error| { + anyhow::anyhow!( + "workspace capsule component path is unsafe ({}): {error}", + component.path.display() + ) + })?; + } + Ok(()) + } + /// Load a capsule into the Kernel from a directory containing a Capsule.toml /// /// # Errors @@ -816,11 +856,14 @@ impl Kernel { dir: PathBuf, principal: &PrincipalId, ) -> Result<(), anyhow::Error> { + self.verify_workspace_capsule_tree(&dir)?; let manifest_path = dir.join("Capsule.toml"); let manifest = astrid_capsule::discovery::load_manifest(&manifest_path) .map_err(|e| anyhow::anyhow!(e))?; + self.verify_workspace_component_paths(&dir, &manifest)?; let id = astrid_capsule_types::CapsuleId::from_static(&manifest.package.name); let wasm_hash = capsule_instance_hash(&manifest, &dir); + self.verify_workspace_capsule_tree(&dir)?; // Dedup by content hash (issue #1069). Runtime instances are shared by // hash across principals: a hash referenced by N principals loads ONCE. @@ -932,6 +975,7 @@ impl Kernel { manifest: astrid_capsule_types::manifest::CapsuleManifest, dir: &std::path::Path, ) -> Result, anyhow::Error> { + self.verify_workspace_capsule_tree(dir)?; let load_principal = PrincipalId::default(); let loader = astrid_capsule::loader::CapsuleLoader::new( @@ -974,6 +1018,7 @@ impl Kernel { let _ = kv.set(&k, v.into_bytes()).await; } } + self.verify_workspace_capsule_tree(dir)?; let ctx = astrid_capsule::context::CapsuleContext::new( load_principal, @@ -1360,7 +1405,7 @@ impl Kernel { ); let discovered = astrid_capsule::discovery::discover_manifests_in_workspace( Some(&paths), - None, + Some(&self.workspace_root), &self.workspace_layout, ); match toposort_manifests(discovered) { @@ -1599,9 +1644,12 @@ impl Kernel { for (principal, capsule) in &capsules { let principal = principal.to_string(); let name = capsule.id().to_string(); - let mut meta = capsule - .source_dir() - .and_then(capsules_loaded::read_capsule_meta_opaque); + let mut meta = capsule.source_dir().and_then(|source_dir| { + self.verify_workspace_capsule_tree(source_dir).ok()?; + let meta = capsules_loaded::read_capsule_meta_opaque(source_dir); + self.verify_workspace_capsule_tree(source_dir).ok()?; + meta + }); // Probe the live instance for its tool surface and inject it. Best- // effort: a describe (or serialize) failure leaves `tools` absent @@ -2969,17 +3017,8 @@ fn capsule_discovery_paths_for( principal: &PrincipalId, workspace_layout: &WorkspaceLayout, ) -> Vec { - let mut paths = vec![home.principal_home(principal).capsules_dir()]; - match workspace_layout - .resolve(workspace_root) - .and_then(|workspace| workspace.capsules_dir()) - { - Ok(path) => paths.push(path), - Err(error) => { - tracing::warn!(%error, "Unsafe workspace capsule path; skipping workspace capsules"); - }, - } - paths + let _ = (workspace_root, workspace_layout); + vec![home.principal_home(principal).capsules_dir()] } #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] @@ -3587,34 +3626,24 @@ mod tests { } #[test] - fn capsule_discovery_paths_include_workspace_capsules() { + fn capsule_discovery_extra_paths_include_principal_capsules_only() { let (_d, home) = scratch_home(); let workspace = tempfile::tempdir().unwrap(); let paths = capsule_discovery_paths(&home, workspace.path()); let default = astrid_core::PrincipalId::default(); - assert_eq!( - paths, - vec![ - home.principal_home(&default).capsules_dir(), - workspace.path().join(".astrid").join("capsules"), - ], - "daemon discovery must scan the default install dir and the configured workspace" - ); + assert_eq!(paths, vec![home.principal_home(&default).capsules_dir()]); } #[test] - fn capsule_discovery_paths_use_injected_workspace_layout() { + fn workspace_capsules_are_not_flattened_into_unchecked_extra_paths() { let (_d, home) = scratch_home(); let workspace = tempfile::tempdir().unwrap(); let layout = WorkspaceLayout::new(".alternate-runtime").unwrap(); let paths = capsule_discovery_paths_for(&home, workspace.path(), &PrincipalId::default(), &layout); - assert_eq!( - paths[1], - workspace.path().join(".alternate-runtime").join("capsules") - ); + assert_eq!(paths.len(), 1); } #[tokio::test(flavor = "multi_thread")] From 9c28fa9966cdefd4199a6781f9ae0727f341797f Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Thu, 16 Jul 2026 00:08:04 +0400 Subject: [PATCH 19/20] fix(ci): restore workspace portability checks --- crates/astrid-core/src/dirs.rs | 377 +----------------- crates/astrid-core/src/workspace_security.rs | 379 +++++++++++++++++++ crates/astrid-kernel/src/lib.rs | 1 - 3 files changed, 385 insertions(+), 372 deletions(-) create mode 100644 crates/astrid-core/src/workspace_security.rs diff --git a/crates/astrid-core/src/dirs.rs b/crates/astrid-core/src/dirs.rs index 2b2d0f9ae..60dbd0d03 100644 --- a/crates/astrid-core/src/dirs.rs +++ b/crates/astrid-core/src/dirs.rs @@ -60,7 +60,6 @@ use std::io; use std::path::{Component, Path, PathBuf}; use std::str::FromStr; -use blake3::Hasher; use uuid::Uuid; use crate::principal::PrincipalId; @@ -77,18 +76,12 @@ pub struct WorkspaceLayout { state_dir_name: String, } -/// A checked project workspace selection. -/// -/// The project root is canonical and the selected state directory is either -/// absent or a real directory directly beneath that root. Symlinks, junctions, -/// and other redirects are rejected by requiring an existing directory to -/// canonicalize to the exact direct-child path selected here. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorkspaceSelection { - project_root: PathBuf, - state_dir: PathBuf, - layout: WorkspaceLayout, -} +#[path = "workspace_security.rs"] +mod workspace_security; + +pub use workspace_security::{ + WorkspaceSelection, checked_workspace_selection_fingerprint, workspace_selection_fingerprint, +}; impl WorkspaceLayout { /// Create a layout from one portable relative directory name. @@ -196,364 +189,6 @@ impl WorkspaceLayout { } } -impl WorkspaceSelection { - fn resolve(project_root: &Path, layout: WorkspaceLayout) -> io::Result { - let project_root = std::fs::canonicalize(project_root).map_err(|error| { - io::Error::new( - error.kind(), - format!( - "failed to resolve workspace root {}: {error}", - project_root.display() - ), - ) - })?; - if !std::fs::metadata(&project_root)?.is_dir() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!( - "workspace root is not a directory: {}", - project_root.display() - ), - )); - } - - let state_dir = project_root.join(layout.state_dir_name()); - verify_state_dir_path(&project_root, &state_dir)?; - Ok(Self { - project_root, - state_dir, - layout, - }) - } - - /// Canonical project root used by every workspace filesystem consumer. - #[must_use] - pub fn project_root(&self) -> &Path { - &self.project_root - } - - /// Checked selected state directory. - #[must_use] - pub fn state_dir(&self) -> &Path { - &self.state_dir - } - - /// Selected workspace layout. - #[must_use] - pub fn layout(&self) -> &WorkspaceLayout { - &self.layout - } - - /// Checked capsule directory under selected project state. - /// - /// # Errors - /// - /// Returns an error if an existing path component is redirected or is not - /// a directory. - pub fn capsules_dir(&self) -> io::Result { - self.resolve_directory("capsules") - } - - /// Checked configuration path under selected project state. - /// - /// # Errors - /// - /// Returns an error if the existing file or one of its parents is - /// redirected or has the wrong type. - pub fn config_path(&self) -> io::Result { - self.resolve_file("config.toml") - } - - /// Checked hooks directory under selected project state. - /// - /// # Errors - /// - /// Returns an error if an existing path component is redirected or is not - /// a directory. - pub fn hooks_dir(&self) -> io::Result { - self.resolve_directory("hooks") - } - - /// Resolve a relative directory beneath selected project state without - /// following an existing redirect. - /// - /// Missing components are allowed so callers can validate before creation. - /// - /// # Errors - /// - /// Returns an error for non-normal relative components, redirects, or an - /// existing non-directory component. - pub fn resolve_directory(&self, relative: impl AsRef) -> io::Result { - self.resolve_descendant(relative.as_ref(), DescendantKind::Directory) - } - - /// Resolve a relative file beneath selected project state without following - /// an existing redirect. - /// - /// Missing components are allowed so callers can validate before creation. - /// - /// # Errors - /// - /// Returns an error for non-normal relative components, redirects, a - /// non-directory parent, or an existing non-file target. - pub fn resolve_file(&self, relative: impl AsRef) -> io::Result { - self.resolve_descendant(relative.as_ref(), DescendantKind::File) - } - - /// Verify every existing entry in a workspace-relative tree without - /// following redirects. - /// - /// # Errors - /// - /// Returns an error if the root is unsafe, any descendant is a symlink, - /// reparse redirect, or special file, or the tree changes while walking. - pub fn verify_tree(&self, relative: impl AsRef) -> io::Result { - let relative = relative.as_ref(); - let root = self.resolve_directory(relative)?; - if !root.exists() { - return Ok(root); - } - let mut pending = vec![root.clone()]; - while let Some(dir) = pending.pop() { - for entry in std::fs::read_dir(&dir)? { - let entry = entry?; - let path = entry.path(); - let metadata = std::fs::symlink_metadata(&path)?; - if metadata.file_type().is_symlink() - || (!metadata.is_dir() && !metadata.is_file()) - || std::fs::canonicalize(&path)? != path - { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!( - "workspace tree contains a redirected or special entry: {}", - path.display() - ), - )); - } - if metadata.is_dir() { - pending.push(path); - } - } - } - self.resolve_directory(relative)?; - Ok(root) - } - - fn resolve_descendant(&self, relative: &Path, kind: DescendantKind) -> io::Result { - self.verify()?; - let components = relative.components().collect::>(); - if components.is_empty() - || components - .iter() - .any(|component| !matches!(component, Component::Normal(_))) - { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "workspace descendant must be a non-empty relative path without traversal", - )); - } - - let mut current = self.state_dir.clone(); - for (index, component) in components.iter().enumerate() { - let Component::Normal(component) = component else { - unreachable!("components validated above") - }; - current.push(component); - let metadata = match std::fs::symlink_metadata(¤t) { - Ok(metadata) => metadata, - Err(error) if error.kind() == io::ErrorKind::NotFound => continue, - Err(error) => return Err(error), - }; - let final_component = index == components.len().saturating_sub(1); - let expected_file = final_component && kind == DescendantKind::File; - if metadata.file_type().is_symlink() - || (expected_file && !metadata.is_file()) - || (!expected_file && !metadata.is_dir()) - { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!( - "workspace path must not contain redirects or unexpected file types: {}", - current.display() - ), - )); - } - if std::fs::canonicalize(¤t)? != current { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!( - "workspace path redirects from its selected target: {}", - current.display() - ), - )); - } - } - Ok(self.state_dir.join(relative)) - } - - /// Re-check that the selected state path has not been redirected. - /// - /// A missing state directory remains valid. This permits a checked - /// selection to be created before initialization while still rejecting a - /// later symlink or non-directory replacement. - /// - /// # Errors - /// - /// Returns an error if the project root or state path no longer satisfies - /// the original selection. - pub fn verify(&self) -> io::Result<()> { - let current = Self::resolve(&self.project_root, self.layout.clone())?; - if current.project_root != self.project_root || current.state_dir != self.state_dir { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "workspace selection changed after validation", - )); - } - Ok(()) - } - - /// Create the selected state directory and verify it again afterwards. - /// - /// # Errors - /// - /// Returns an error if creation fails or the path is redirected before or - /// after creation. - pub fn ensure_state_dir(&self) -> io::Result<()> { - self.verify()?; - match std::fs::create_dir(&self.state_dir) { - Ok(()) => {}, - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}, - Err(error) => return Err(error), - } - self.verify() - } - - /// Create a checked relative directory and verify the full path afterwards. - /// - /// # Errors - /// - /// Returns an error if a component is unsafe, creation fails, or a redirect - /// appears during creation. - pub fn ensure_directory(&self, relative: impl AsRef) -> io::Result { - let relative = relative.as_ref(); - let path = self.resolve_directory(relative)?; - self.ensure_state_dir()?; - std::fs::create_dir_all(&path)?; - let checked = self.resolve_directory(relative)?; - self.verify()?; - Ok(checked) - } - - /// Stable opaque identity for the checked root and state-directory target. - #[must_use] - pub fn fingerprint(&self) -> String { - let mut hasher = Hasher::new(); - hasher.update(b"astrid-workspace-selection-v2\0"); - hash_path(&mut hasher, &self.project_root); - hasher.update(b"\0"); - hash_path(&mut hasher, &self.state_dir); - hasher.update(b"\0"); - hasher.update(self.layout.state_dir_name().as_bytes()); - hex::encode(hasher.finalize().as_bytes()) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum DescendantKind { - Directory, - File, -} - -fn verify_state_dir_path(project_root: &Path, state_dir: &Path) -> io::Result<()> { - let metadata = match std::fs::symlink_metadata(state_dir) { - Ok(metadata) => metadata, - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), - Err(error) => return Err(error), - }; - if metadata.file_type().is_symlink() || !metadata.is_dir() { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!( - "workspace state path must be a real directory, not a redirect or file: {}", - state_dir.display() - ), - )); - } - - let canonical = std::fs::canonicalize(state_dir)?; - if canonical != state_dir || canonical.parent() != Some(project_root) { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!( - "workspace state directory escapes or redirects outside its selected path: {}", - state_dir.display() - ), - )); - } - Ok(()) -} - -/// Stable identity for one project root and workspace layout selection. -/// -/// The identity is suitable for detecting whether a CLI and an already-running -/// daemon selected the same project. It does not expose the project path. -#[must_use] -pub fn workspace_selection_fingerprint( - project_root: &Path, - workspace_layout: &WorkspaceLayout, -) -> String { - if let Ok(selection) = workspace_layout.resolve(project_root) { - return selection.fingerprint(); - } - let root = std::fs::canonicalize(project_root).unwrap_or_else(|_| { - if project_root.is_absolute() { - project_root.to_path_buf() - } else { - std::env::current_dir() - .map_or_else(|_| project_root.to_path_buf(), |cwd| cwd.join(project_root)) - } - }); - let mut hasher = Hasher::new(); - hasher.update(b"astrid-workspace-selection-v1\0"); - hash_path(&mut hasher, &root); - hasher.update(b"\0"); - hasher.update(workspace_layout.state_dir_name().as_bytes()); - hex::encode(hasher.finalize().as_bytes()) -} - -/// Resolve a workspace safely and derive its opaque selection identity. -/// -/// Security-sensitive callers must use this fallible form rather than relying -/// on a lexical path fingerprint. -/// -/// # Errors -/// -/// Returns an error if the workspace root or selected state path is unsafe. -pub fn checked_workspace_selection_fingerprint( - project_root: &Path, - workspace_layout: &WorkspaceLayout, -) -> io::Result { - Ok(workspace_layout.resolve(project_root)?.fingerprint()) -} - -fn hash_path(hasher: &mut Hasher, path: &Path) { - #[cfg(unix)] - { - use std::os::unix::ffi::OsStrExt as _; - hasher.update(path.as_os_str().as_bytes()); - } - #[cfg(windows)] - { - use std::os::windows::ffi::OsStrExt as _; - for unit in path.as_os_str().encode_wide() { - hasher.update(&unit.to_le_bytes()); - } - } - #[cfg(not(any(unix, windows)))] - hasher.update(path.as_os_str().to_string_lossy().as_bytes()); -} - impl Default for WorkspaceLayout { fn default() -> Self { Self { diff --git a/crates/astrid-core/src/workspace_security.rs b/crates/astrid-core/src/workspace_security.rs new file mode 100644 index 000000000..3ef009d4a --- /dev/null +++ b/crates/astrid-core/src/workspace_security.rs @@ -0,0 +1,379 @@ +//! Checked workspace filesystem boundaries. + +use std::io; +use std::path::{Component, Path, PathBuf}; + +use blake3::Hasher; + +use super::WorkspaceLayout; + +/// A checked project workspace selection. +/// +/// The project root is canonical and the selected state directory is either +/// absent or a real directory directly beneath that root. Symlinks, junctions, +/// and other redirects are rejected by requiring an existing directory to +/// canonicalize to the exact direct-child path selected here. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkspaceSelection { + project_root: PathBuf, + state_dir: PathBuf, + layout: WorkspaceLayout, +} + +impl WorkspaceSelection { + pub(super) fn resolve(project_root: &Path, layout: WorkspaceLayout) -> io::Result { + let project_root = std::fs::canonicalize(project_root).map_err(|error| { + io::Error::new( + error.kind(), + format!( + "failed to resolve workspace root {}: {error}", + project_root.display() + ), + ) + })?; + if !std::fs::metadata(&project_root)?.is_dir() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "workspace root is not a directory: {}", + project_root.display() + ), + )); + } + + let state_dir = project_root.join(layout.state_dir_name()); + verify_state_dir_path(&project_root, &state_dir)?; + Ok(Self { + project_root, + state_dir, + layout, + }) + } + + /// Canonical project root used by every workspace filesystem consumer. + #[must_use] + pub fn project_root(&self) -> &Path { + &self.project_root + } + + /// Checked selected state directory. + #[must_use] + pub fn state_dir(&self) -> &Path { + &self.state_dir + } + + /// Selected workspace layout. + #[must_use] + pub fn layout(&self) -> &WorkspaceLayout { + &self.layout + } + + /// Checked capsule directory under selected project state. + /// + /// # Errors + /// + /// Returns an error if an existing path component is redirected or is not + /// a directory. + pub fn capsules_dir(&self) -> io::Result { + self.resolve_directory("capsules") + } + + /// Checked configuration path under selected project state. + /// + /// # Errors + /// + /// Returns an error if the existing file or one of its parents is + /// redirected or has the wrong type. + pub fn config_path(&self) -> io::Result { + self.resolve_file("config.toml") + } + + /// Checked hooks directory under selected project state. + /// + /// # Errors + /// + /// Returns an error if an existing path component is redirected or is not + /// a directory. + pub fn hooks_dir(&self) -> io::Result { + self.resolve_directory("hooks") + } + + /// Resolve a relative directory beneath selected project state without + /// following an existing redirect. + /// + /// Missing components are allowed so callers can validate before creation. + /// + /// # Errors + /// + /// Returns an error for non-normal relative components, redirects, or an + /// existing non-directory component. + pub fn resolve_directory(&self, relative: impl AsRef) -> io::Result { + self.resolve_descendant(relative.as_ref(), DescendantKind::Directory) + } + + /// Resolve a relative file beneath selected project state without following + /// an existing redirect. + /// + /// Missing components are allowed so callers can validate before creation. + /// + /// # Errors + /// + /// Returns an error for non-normal relative components, redirects, a + /// non-directory parent, or an existing non-file target. + pub fn resolve_file(&self, relative: impl AsRef) -> io::Result { + self.resolve_descendant(relative.as_ref(), DescendantKind::File) + } + + /// Verify every existing entry in a workspace-relative tree without + /// following redirects. + /// + /// # Errors + /// + /// Returns an error if the root is unsafe, any descendant is a symlink, + /// reparse redirect, or special file, or the tree changes while walking. + pub fn verify_tree(&self, relative: impl AsRef) -> io::Result { + let relative = relative.as_ref(); + let root = self.resolve_directory(relative)?; + if !root.exists() { + return Ok(root); + } + let mut pending = vec![root.clone()]; + while let Some(dir) = pending.pop() { + for entry in std::fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + let metadata = std::fs::symlink_metadata(&path)?; + if metadata.file_type().is_symlink() + || (!metadata.is_dir() && !metadata.is_file()) + || std::fs::canonicalize(&path)? != path + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "workspace tree contains a redirected or special entry: {}", + path.display() + ), + )); + } + if metadata.is_dir() { + pending.push(path); + } + } + } + self.resolve_directory(relative)?; + Ok(root) + } + + fn resolve_descendant(&self, relative: &Path, kind: DescendantKind) -> io::Result { + self.verify()?; + let components = relative.components().collect::>(); + if components.is_empty() + || components + .iter() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "workspace descendant must be a non-empty relative path without traversal", + )); + } + + let mut current = self.state_dir.clone(); + for (index, component) in components.iter().enumerate() { + let Component::Normal(component) = component else { + unreachable!("components validated above") + }; + current.push(component); + let metadata = match std::fs::symlink_metadata(¤t) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => continue, + Err(error) => return Err(error), + }; + let final_component = index == components.len().saturating_sub(1); + let expected_file = final_component && kind == DescendantKind::File; + if metadata.file_type().is_symlink() + || (expected_file && !metadata.is_file()) + || (!expected_file && !metadata.is_dir()) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "workspace path must not contain redirects or unexpected file types: {}", + current.display() + ), + )); + } + if std::fs::canonicalize(¤t)? != current { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "workspace path redirects from its selected target: {}", + current.display() + ), + )); + } + } + Ok(self.state_dir.join(relative)) + } + + /// Re-check that the selected state path has not been redirected. + /// + /// A missing state directory remains valid. This permits a checked + /// selection to be created before initialization while still rejecting a + /// later symlink or non-directory replacement. + /// + /// # Errors + /// + /// Returns an error if the project root or state path no longer satisfies + /// the original selection. + pub fn verify(&self) -> io::Result<()> { + let current = Self::resolve(&self.project_root, self.layout.clone())?; + if current.project_root != self.project_root || current.state_dir != self.state_dir { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "workspace selection changed after validation", + )); + } + Ok(()) + } + + /// Create the selected state directory and verify it again afterwards. + /// + /// # Errors + /// + /// Returns an error if creation fails or the path is redirected before or + /// after creation. + pub fn ensure_state_dir(&self) -> io::Result<()> { + self.verify()?; + match std::fs::create_dir(&self.state_dir) { + Ok(()) => {}, + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}, + Err(error) => return Err(error), + } + self.verify() + } + + /// Create a checked relative directory and verify the full path afterwards. + /// + /// # Errors + /// + /// Returns an error if a component is unsafe, creation fails, or a redirect + /// appears during creation. + pub fn ensure_directory(&self, relative: impl AsRef) -> io::Result { + let relative = relative.as_ref(); + let path = self.resolve_directory(relative)?; + self.ensure_state_dir()?; + std::fs::create_dir_all(&path)?; + let checked = self.resolve_directory(relative)?; + self.verify()?; + Ok(checked) + } + + /// Stable opaque identity for the checked root and state-directory target. + #[must_use] + pub fn fingerprint(&self) -> String { + let mut hasher = Hasher::new(); + hasher.update(b"astrid-workspace-selection-v2\0"); + hash_path(&mut hasher, &self.project_root); + hasher.update(b"\0"); + hash_path(&mut hasher, &self.state_dir); + hasher.update(b"\0"); + hasher.update(self.layout.state_dir_name().as_bytes()); + hex::encode(hasher.finalize().as_bytes()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DescendantKind { + Directory, + File, +} + +fn verify_state_dir_path(project_root: &Path, state_dir: &Path) -> io::Result<()> { + let metadata = match std::fs::symlink_metadata(state_dir) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error), + }; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "workspace state path must be a real directory, not a redirect or file: {}", + state_dir.display() + ), + )); + } + + let canonical = std::fs::canonicalize(state_dir)?; + if canonical != state_dir || canonical.parent() != Some(project_root) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "workspace state directory escapes or redirects outside its selected path: {}", + state_dir.display() + ), + )); + } + Ok(()) +} + +/// Stable identity for one project root and workspace layout selection. +/// +/// The identity is suitable for detecting whether a CLI and an already-running +/// daemon selected the same project. It does not expose the project path. +#[must_use] +pub fn workspace_selection_fingerprint( + project_root: &Path, + workspace_layout: &WorkspaceLayout, +) -> String { + if let Ok(selection) = workspace_layout.resolve(project_root) { + return selection.fingerprint(); + } + let root = std::fs::canonicalize(project_root).unwrap_or_else(|_| { + if project_root.is_absolute() { + project_root.to_path_buf() + } else { + std::env::current_dir() + .map_or_else(|_| project_root.to_path_buf(), |cwd| cwd.join(project_root)) + } + }); + let mut hasher = Hasher::new(); + hasher.update(b"astrid-workspace-selection-v1\0"); + hash_path(&mut hasher, &root); + hasher.update(b"\0"); + hasher.update(workspace_layout.state_dir_name().as_bytes()); + hex::encode(hasher.finalize().as_bytes()) +} + +/// Resolve a workspace safely and derive its opaque selection identity. +/// +/// Security-sensitive callers must use this fallible form rather than relying +/// on a lexical path fingerprint. +/// +/// # Errors +/// +/// Returns an error if the workspace root or selected state path is unsafe. +pub fn checked_workspace_selection_fingerprint( + project_root: &Path, + workspace_layout: &WorkspaceLayout, +) -> io::Result { + Ok(workspace_layout.resolve(project_root)?.fingerprint()) +} + +fn hash_path(hasher: &mut Hasher, path: &Path) { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt as _; + hasher.update(path.as_os_str().as_bytes()); + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt as _; + for unit in path.as_os_str().encode_wide() { + hasher.update(&unit.to_le_bytes()); + } + } + #[cfg(not(any(unix, windows)))] + hasher.update(path.as_os_str().to_string_lossy().as_bytes()); +} diff --git a/crates/astrid-kernel/src/lib.rs b/crates/astrid-kernel/src/lib.rs index c24774c8e..064cb2867 100644 --- a/crates/astrid-kernel/src/lib.rs +++ b/crates/astrid-kernel/src/lib.rs @@ -805,7 +805,6 @@ impl Kernel { Ok(kernel) } - #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] fn verify_workspace_capsule_tree(&self, dir: &Path) -> anyhow::Result<()> { if let Ok(relative) = dir.strip_prefix(self.workspace_selection.state_dir()) { self.workspace_selection From 373912d1f304fa24e07aae60719d2328ffe43cc3 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Thu, 16 Jul 2026 00:15:58 +0400 Subject: [PATCH 20/20] refactor(kernel): isolate capsule visibility --- crates/astrid-kernel/src/kernel_router/mod.rs | 49 ++--------------- .../src/kernel_router/visibility.rs | 53 +++++++++++++++++++ 2 files changed, 56 insertions(+), 46 deletions(-) create mode 100644 crates/astrid-kernel/src/kernel_router/visibility.rs diff --git a/crates/astrid-kernel/src/kernel_router/mod.rs b/crates/astrid-kernel/src/kernel_router/mod.rs index 4a9bf982d..d110a4ee9 100644 --- a/crates/astrid-kernel/src/kernel_router/mod.rs +++ b/crates/astrid-kernel/src/kernel_router/mod.rs @@ -9,12 +9,13 @@ mod install; mod rate_limit; /// Kernel-response publishing envelope + the long-request keepalive pinger. mod response; +mod visibility; pub(crate) use rate_limit::rate_limit_for_request; pub(crate) use response::{KeepalivePinger, publish_response, workspace_commit_response}; use astrid_runtime::time::Instant; -use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque}; +use std::collections::{BTreeMap, HashMap, VecDeque}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -31,6 +32,7 @@ use tracing::{debug, info, warn}; use caller::CallerResolutionError; use caller::{MANAGEMENT_CALLER_REQUIRED, resolve_caller, resolve_connection_principal}; use device_scope::resolve_device_scope; +use visibility::CapsuleVisibility; #[cfg(test)] mod capability_catalog_tests; @@ -597,51 +599,6 @@ async fn unregister_failed_capsules(kernel: &crate::Kernel) { } } -struct CapsuleVisibility { - principal: PrincipalId, - is_admin: bool, - capsule_grants: BTreeSet, -} - -impl CapsuleVisibility { - fn new(authorization: &AuthorizedRequest) -> Self { - if authorization.principal.as_str() == "anonymous" { - return Self::denied(&authorization.principal); - } - let profile = authorization.profile.as_ref(); - let check = authorization.capability_check(); - - Self { - principal: authorization.principal.clone(), - is_admin: check.has("capsule:list"), - capsule_grants: profile.capsules.iter().cloned().collect(), - } - } - - fn denied(caller: &PrincipalId) -> Self { - Self { - principal: caller.clone(), - is_admin: false, - capsule_grants: BTreeSet::new(), - } - } - - fn allows(&self, capsule_id: &astrid_capsule::capsule::CapsuleId) -> bool { - self.is_admin || self.capsule_grants.contains(capsule_id.as_str()) - } - - fn capsules( - &self, - registry: &astrid_capsule::registry::CapsuleRegistry, - ) -> Vec> { - if self.is_admin { - registry.cloned_values() - } else { - registry.cloned_values_for(&self.principal) - } - } -} - // --------------------------------------------------------------------------- // Management API rate limiting // --------------------------------------------------------------------------- diff --git a/crates/astrid-kernel/src/kernel_router/visibility.rs b/crates/astrid-kernel/src/kernel_router/visibility.rs new file mode 100644 index 000000000..bfcbb0cc4 --- /dev/null +++ b/crates/astrid-kernel/src/kernel_router/visibility.rs @@ -0,0 +1,53 @@ +//! Caller-scoped capsule inventory visibility. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use astrid_core::principal::PrincipalId; + +use super::AuthorizedRequest; + +pub(super) struct CapsuleVisibility { + pub(super) principal: PrincipalId, + is_admin: bool, + capsule_grants: BTreeSet, +} + +impl CapsuleVisibility { + pub(super) fn new(authorization: &AuthorizedRequest) -> Self { + if authorization.principal.as_str() == "anonymous" { + return Self::denied(&authorization.principal); + } + let profile = authorization.profile.as_ref(); + let check = authorization.capability_check(); + + Self { + principal: authorization.principal.clone(), + is_admin: check.has("capsule:list"), + capsule_grants: profile.capsules.iter().cloned().collect(), + } + } + + fn denied(caller: &PrincipalId) -> Self { + Self { + principal: caller.clone(), + is_admin: false, + capsule_grants: BTreeSet::new(), + } + } + + pub(super) fn allows(&self, capsule_id: &astrid_capsule::capsule::CapsuleId) -> bool { + self.is_admin || self.capsule_grants.contains(capsule_id.as_str()) + } + + pub(super) fn capsules( + &self, + registry: &astrid_capsule::registry::CapsuleRegistry, + ) -> Vec> { + if self.is_admin { + registry.cloned_values() + } else { + registry.cloned_values_for(&self.principal) + } + } +}