use std::collections::HashSet;
use std::path::Path;
use murmur_artifact::{ArtifactName, ArtifactRuntime, LockOrigin, MurmurLock};
use crate::install_grant::{self, NOT_GRANTED};
use crate::types::{CapabilityPolicy, InstalledArtifactSummary};
pub(crate) const NOT_REMOVABLE: &str = "not-removable:";
pub(crate) fn refuse_before_lock<'a>(
policy: &CapabilityPolicy,
name: &str,
installed: &'a [InstalledArtifactSummary],
declared: &HashSet<String>,
) -> Result<Option<&'a InstalledArtifactSummary>, String> {
if !install_grant::is_granted(policy) {
return Err(format!(
"{NOT_GRANTED} this capsule declares no capabilities.install, so removing '{name}' is \
refused"
));
}
if let Err(error) = ArtifactName::parse(name) {
return Err(install_grant::name_refusal(&error));
}
let Some(summary) = installed.iter().find(|artifact| artifact.name == name) else {
return Ok(None);
};
let fixed_role = match summary.runtime {
ArtifactRuntime::Hook => Some("hook"),
ArtifactRuntime::Driver => Some("driver"),
ArtifactRuntime::Tool | ArtifactRuntime::Skill => None,
};
if let Some(role) = fixed_role {
return Err(format!(
"{NOT_REMOVABLE} '{name}' is a {role}; hooks and drivers are fixed at launch and \
cannot be removed while the session runs"
));
}
if declared.contains(name) {
return Err(format!(
"{NOT_REMOVABLE} '{name}' is declared in murmur.yaml; an artifact the operator \
declared stays for the life of the session"
));
}
if summary.origin == LockOrigin::Operator {
return Err(operator_pinned(name));
}
Ok(Some(summary))
}
pub(crate) fn refuse_against_lock(name: &str, lock: &MurmurLock) -> Option<String> {
let Some(entry) = lock.artifact_for(name) else {
return Some(format!(
"{NOT_REMOVABLE} murmur.lock has no entry for '{name}', so nothing records that this \
capsule pulled it"
));
};
(entry.origin == LockOrigin::Operator).then(|| operator_pinned(name))
}
pub(crate) fn called_after_removal(name: &str) -> String {
format!("tool '{name}' was removed from this session; it can no longer be called")
}
pub(crate) fn undeletable_directory(name: &str, dir: &Path, err: &std::io::Error) -> String {
format!(
"'{name}' is removed from this session and from murmur.lock, but {} could not be \
deleted: {err}",
dir.display()
)
}
fn operator_pinned(name: &str) -> String {
format!(
"{NOT_REMOVABLE} murmur.lock pins '{name}' with origin: operator; only an artifact this \
capsule pulled (origin: runtime) can be removed"
)
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use murmur_artifact::{
read_lockfile, write_lockfile_atomic, ArtifactRuntime, LockOrigin, LockedArtifact,
LockedSha256, MurmurLock, LOCK_VERSION, PACKED_MANIFEST_ENTRY,
};
use murmur_artifact::tool_name_of;
use super::*;
use crate::agent::inventory::build_tool_inventory;
use crate::bindings::host::murmur::{
artifact_manager::manage, tool::run::ToolInput, tool_registry::invoke,
};
use crate::install_grant::{pull, tree, Project, VERSION};
use crate::runtime::{cwasm_entries, scratch_compiled_dir, CapsuleStoreState};
const SKILL: &str = "local/late-skill";
const SKILL_TOOL: &str = "late-skill";
const SKILL_TEXT: &str = "# late guidance";
fn under_scratch_home(inner: &str) {
let home = tempfile::tempdir().unwrap();
crate::murmur_home::run_with_home(
&format!("artifact_removal::tests::{inner}"),
home.path(),
);
}
fn remove(state: &mut CapsuleStoreState, name: &str) -> Result<bool, String> {
manage::Host::remove(state, name.to_string())
}
fn input() -> ToolInput {
ToolInput {
data: Some(r#"{"command":"--version"}"#.to_string()),
log_path: None,
}
}
fn listed(state: &mut CapsuleStoreState) -> Vec<String> {
manage::Host::list(state)
.into_iter()
.map(|summary| summary.name)
.collect()
}
fn lock_bytes(project: &Project) -> Option<Vec<u8>> {
fs::read(&project.lock_path).ok()
}
#[derive(Debug, PartialEq)]
struct Snapshot {
tree: Vec<PathBuf>,
lock: Option<Vec<u8>>,
installed: Vec<InstalledArtifactSummary>,
components: Vec<String>,
removed: HashSet<String>,
generation: u64,
}
fn snapshot(project: &Project, state: &CapsuleStoreState) -> Snapshot {
let mut components: Vec<String> = state.tool_components.keys().cloned().collect();
components.sort();
Snapshot {
tree: tree(&project.workdir),
lock: lock_bytes(project),
installed: state.installed_artifacts.clone(),
components,
removed: state.removed_artifacts.clone(),
generation: state.installed_generation,
}
}
async fn dispatch(state: &CapsuleStoreState, name: &str) -> Result<String, String> {
state
.dispatch_agent_tool_async(name, input(), None, None)
.await
.map(|outcome| outcome.result.data.unwrap_or_default())
}
fn assert_gone(project: &Project, state: &mut CapsuleStoreState, name: &str) {
assert!(!project.installed(name).exists());
assert!(!project.locked(name));
assert!(state.installed_artifacts.iter().all(|a| a.name != name));
assert!(!listed(state).contains(&name.to_string()));
let err = manage::Host::describe(state, name.to_string())
.expect_err("a removed artifact is not described");
assert!(err.contains("is not installed"), "{err}");
assert!(state.removed_artifacts.contains(tool_name_of(name)));
}
fn summary(
name: &str,
runtime: ArtifactRuntime,
origin: LockOrigin,
) -> InstalledArtifactSummary {
InstalledArtifactSummary {
name: name.to_string(),
version: VERSION.to_string(),
runtime,
origin,
}
}
fn runtime_origin(session: &str) -> LockOrigin {
LockOrigin::Runtime {
session: session.to_string(),
}
}
fn stage(
project: &Project,
state: &mut CapsuleStoreState,
artifacts: &[InstalledArtifactSummary],
) {
let mut lock = MurmurLock {
lock_version: LOCK_VERSION,
artifacts: Vec::new(),
};
for artifact in artifacts {
let dir = project.installed(&artifact.name);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join(PACKED_MANIFEST_ENTRY),
format!(
"name: {}\nversion: {VERSION}\nruntime: tool\n",
tool_name_of(&artifact.name)
),
)
.unwrap();
lock.artifacts.push(LockedArtifact {
name: artifact.name.clone(),
resolved_version: VERSION.to_string(),
sha256: LockedSha256::any("aaa"),
origin: artifact.origin.clone(),
owner_id: None,
signer: None,
});
state.installed_artifacts.push(artifact.clone());
}
write_lockfile_atomic(&project.lock_path, &lock).unwrap();
}
fn operator_pinned_refusal(name: &str) -> String {
format!(
"not-removable: murmur.lock pins '{name}' with origin: operator; only an artifact \
this capsule pulled (origin: runtime) can be removed"
)
}
#[tokio::test(flavor = "multi_thread")]
async fn a_pulled_skill_is_removed_from_every_place_it_was_installed() {
let project = Project::new();
project.publish_skill(SKILL, SKILL_TEXT);
let mut state = project.session(&[SKILL], &[]);
pull(&mut state, SKILL).expect("a granted skill pulls");
assert!(dispatch(&state, SKILL_TOOL)
.await
.unwrap()
.contains(SKILL_TEXT));
let generation = state.installed_generation;
assert_eq!(remove(&mut state, SKILL), Ok(true));
assert_gone(&project, &mut state, SKILL);
assert_eq!(state.installed_generation, generation + 1);
}
#[test]
fn a_pulled_wasm_tool_is_removed_and_its_component_dropped() {
under_scratch_home("inner_a_pulled_wasm_tool_is_removed_and_its_component_dropped");
}
#[test]
#[ignore = "run by a_pulled_wasm_tool_is_removed_and_its_component_dropped"]
fn inner_a_pulled_wasm_tool_is_removed_and_its_component_dropped() {
if !crate::murmur_home::in_scratch_home() {
return;
}
let project = Project::new();
project.publish_wasm_tool("local/wasm-tool");
let mut state = project.session(&[], &["local/wasm-tool"]);
pull(&mut state, "local/wasm-tool").expect("a granted WASM tool pulls");
assert!(state.tool_components.contains_key("wasm-tool"));
let sha256 = read_lockfile(&project.lock_path)
.unwrap()
.artifact_for("local/wasm-tool")
.unwrap()
.sha256
.any
.clone();
assert_eq!(remove(&mut state, "local/wasm-tool"), Ok(true));
assert_gone(&project, &mut state, "local/wasm-tool");
assert!(!state.tool_components.contains_key("wasm-tool"));
let forms = cwasm_entries(&scratch_compiled_dir());
assert_eq!(forms.len(), 1, "{forms:?}");
let form = forms[0].file_name().unwrap().to_string_lossy().into_owned();
assert!(form.starts_with(&format!("{sha256}-")), "{form}");
}
#[test]
fn a_scoped_artifact_is_removed_by_its_full_name_and_refused_by_its_bare_one() {
let project = Project::new();
project.publish_skill("ana/tool-x", "# x");
let mut state = project.session(&["ana/tool-*"], &[]);
pull(&mut state, "ana/tool-x").expect("a granted scoped skill pulls");
let dir = project.workdir.join("tools").join("tool-x");
assert!(dir.is_dir());
let before = snapshot(&project, &state);
let err = remove(&mut state, "tool-x").expect_err("a bare name is refused");
assert!(
err.starts_with("not-granted: 'tool-x' is not a full artifact name"),
"{err}"
);
assert!(err.contains("'local/tool-x'"), "{err}");
assert_eq!(snapshot(&project, &state), before);
assert_eq!(remove(&mut state, "ana/tool-x"), Ok(true));
assert!(!dir.exists());
assert!(read_lockfile(&project.lock_path)
.unwrap()
.artifact_for("ana/tool-x")
.is_none());
assert_gone(&project, &mut state, "ana/tool-x");
}
#[test]
fn a_removed_artifact_cannot_be_called() {
under_scratch_home("inner_a_removed_artifact_cannot_be_called");
}
#[tokio::test(flavor = "multi_thread")]
#[ignore = "run by a_removed_artifact_cannot_be_called"]
async fn inner_a_removed_artifact_cannot_be_called() {
if !crate::murmur_home::in_scratch_home() {
return;
}
let project = Project::new();
project.publish_skill(SKILL, SKILL_TEXT);
project.publish_wasm_tool("local/wasm-tool");
let mut state = project.session(&[SKILL], &["local/wasm-tool"]);
pull(&mut state, SKILL).unwrap();
pull(&mut state, "local/wasm-tool").unwrap();
state.allowlisted_tools.insert("wasm-tool".to_string());
for name in [SKILL, "local/wasm-tool"] {
assert_eq!(remove(&mut state, name), Ok(true));
let tool = tool_name_of(name);
let refusal = format!(
"tool '{tool}' was removed from this session; it can no longer \
be called"
);
assert_eq!(dispatch(&state, tool).await, Err(refusal.clone()));
assert_eq!(
invoke::Host::invoke(&mut state, tool.to_string(), input()).map(|_| ()),
Err(refusal)
);
}
}
#[test]
fn a_removed_artifact_is_absent_from_a_rebuilt_inventory() {
let project = Project::new();
project.publish_skill(SKILL, SKILL_TEXT);
let existing = project.installed("zzz-existing-tool");
fs::create_dir_all(&existing).unwrap();
fs::write(
existing.join(PACKED_MANIFEST_ENTRY),
"name: zzz-existing-tool\nversion: 1.0.0\nruntime: tool\n",
)
.unwrap();
let mut state = project.session(&[SKILL], &[]);
let names = |state: &CapsuleStoreState| -> Vec<String> {
crate::agent::inventory::tool_names(&build_tool_inventory(
&state.runtime_root,
None,
&state.installed_artifacts,
))
};
pull(&mut state, SKILL).unwrap();
assert_eq!(names(&state), vec![SKILL_TOOL, "zzz-existing-tool"]);
assert_eq!(remove(&mut state, SKILL), Ok(true));
assert_eq!(names(&state), vec!["zzz-existing-tool"]);
}
#[tokio::test(flavor = "multi_thread")]
async fn an_operator_pinned_artifact_cannot_be_removed() {
let project = Project::new();
let sha256 = project.publish_skill(SKILL, SKILL_TEXT);
write_lockfile_atomic(
&project.lock_path,
&MurmurLock {
lock_version: LOCK_VERSION,
artifacts: vec![LockedArtifact {
name: SKILL.to_string(),
resolved_version: VERSION.to_string(),
sha256: LockedSha256::any(sha256),
origin: LockOrigin::Operator,
owner_id: None,
signer: None,
}],
},
)
.unwrap();
let mut state = project.session(&[SKILL], &[]);
pull(&mut state, SKILL).expect("a pull matching the operator pin succeeds");
let before = snapshot(&project, &state);
assert_eq!(
remove(&mut state, SKILL),
Err(operator_pinned_refusal(SKILL))
);
assert_eq!(snapshot(&project, &state), before);
assert!(dispatch(&state, SKILL_TOOL)
.await
.unwrap()
.contains(SKILL_TEXT));
}
#[test]
fn an_artifact_declared_in_murmur_yaml_cannot_be_removed_whatever_its_pin_origin() {
let project = Project::new();
let mut state = project.session(&["*"], &["*"]);
stage(
&project,
&mut state,
&[
summary("local/decl-op", ArtifactRuntime::Tool, LockOrigin::Operator),
summary(
"local/decl-rt",
ArtifactRuntime::Tool,
runtime_origin("ses_prev"),
),
],
);
state.declared_artifacts = ["local/decl-op", "local/decl-rt"].map(String::from).into();
let before = snapshot(&project, &state);
for name in ["local/decl-op", "local/decl-rt"] {
assert_eq!(
remove(&mut state, name),
Err(format!(
"not-removable: '{name}' is declared in murmur.yaml; an artifact the \
operator declared stays for the life of the session"
))
);
}
assert_eq!(snapshot(&project, &state), before);
}
#[test]
fn a_hook_or_driver_cannot_be_removed() {
let project = Project::new();
let mut state = project.session(&["*"], &["*"]);
let cases = [
(
"local/decl-hook",
ArtifactRuntime::Hook,
LockOrigin::Operator,
"hook",
),
(
"local/decl-driver",
ArtifactRuntime::Driver,
LockOrigin::Operator,
"driver",
),
(
"local/rt-hook",
ArtifactRuntime::Hook,
runtime_origin("ses_test"),
"hook",
),
(
"local/rt-driver",
ArtifactRuntime::Driver,
runtime_origin("ses_test"),
"driver",
),
];
let artifacts: Vec<_> = cases
.iter()
.map(|(name, runtime, origin, _)| summary(name, runtime.clone(), origin.clone()))
.collect();
stage(&project, &mut state, &artifacts);
state.declared_artifacts = ["local/decl-hook", "local/decl-driver"]
.map(String::from)
.into();
let before = snapshot(&project, &state);
for (name, _, _, role) in cases {
assert_eq!(
remove(&mut state, name),
Err(format!(
"not-removable: '{name}' is a {role}; hooks and drivers are fixed at launch \
and cannot be removed while the session runs"
))
);
}
assert_eq!(snapshot(&project, &state), before);
}
#[test]
fn a_capsule_without_an_install_grant_cannot_remove() {
let project = Project::new();
project.publish_skill(SKILL, SKILL_TEXT);
let mut state = project.session(&[SKILL], &[]);
pull(&mut state, SKILL).unwrap();
let granted = state.capability_policy.clone();
state.capability_policy = CapabilityPolicy::default();
let before = snapshot(&project, &state);
assert_eq!(
remove(&mut state, SKILL),
Err(format!(
"not-granted: this capsule declares no capabilities.install, so removing \
'{SKILL}' is refused"
))
);
assert_eq!(snapshot(&project, &state), before);
state.capability_policy = granted;
let err = remove(&mut state, "../x").expect_err("a path is not an artifact name");
assert!(
err.starts_with("not-granted: '../x' is not a valid artifact name: "),
"{err}"
);
assert_eq!(snapshot(&project, &state), before);
}
#[test]
fn removing_what_is_not_installed_changes_nothing() {
let project = Project::new();
project.publish_skill(SKILL, SKILL_TEXT);
let mut state = project.session(&[SKILL], &[]);
state.capability_policy.shell_allow = vec!["some-binary".to_string()];
pull(&mut state, SKILL).unwrap();
assert!(listed(&mut state).contains(&"some-binary".to_string()));
let before = snapshot(&project, &state);
assert_eq!(remove(&mut state, "local/never-pulled"), Ok(false));
assert_eq!(snapshot(&project, &state), before);
let err = remove(&mut state, "some-binary").expect_err("a bare name is refused");
assert!(
err.starts_with("not-granted: 'some-binary' is not a full artifact name"),
"{err}"
);
assert_eq!(snapshot(&project, &state), before);
assert_eq!(remove(&mut state, SKILL), Ok(true));
let before = snapshot(&project, &state);
assert_eq!(remove(&mut state, SKILL), Ok(false));
assert_eq!(snapshot(&project, &state), before);
}
#[test]
fn the_lock_decides_at_removal_time() {
let project = Project::new();
project.publish_skill(SKILL, SKILL_TEXT);
let mut state = project.session(&[SKILL], &[]);
pull(&mut state, SKILL).unwrap();
let pulled = read_lockfile(&project.lock_path).unwrap();
let mut adopted = pulled.clone();
adopted.artifacts[0].origin = LockOrigin::Operator;
write_lockfile_atomic(&project.lock_path, &adopted).unwrap();
let before = snapshot(&project, &state);
assert_eq!(
remove(&mut state, SKILL),
Err(operator_pinned_refusal(SKILL))
);
assert_eq!(snapshot(&project, &state), before);
let mut dropped = pulled;
dropped.remove(SKILL);
write_lockfile_atomic(&project.lock_path, &dropped).unwrap();
let before = snapshot(&project, &state);
assert_eq!(
remove(&mut state, SKILL),
Err(format!(
"not-removable: murmur.lock has no entry for '{SKILL}', so nothing records that \
this capsule pulled it"
))
);
assert_eq!(snapshot(&project, &state), before);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_removed_runtime_artifact_can_be_pulled_again_at_another_version() {
let project = Project::new();
project.publish_skill(SKILL, SKILL_TEXT);
project.publish_skill_at(SKILL, "2.0.0", "# second guidance");
let mut state = project.session(&[SKILL], &[]);
pull(&mut state, SKILL).unwrap();
assert_eq!(remove(&mut state, SKILL), Ok(true));
manage::Host::pull(&mut state, SKILL.to_string(), "2.0.0".to_string())
.expect("a removed runtime artifact pulls again");
let lock = read_lockfile(&project.lock_path).unwrap();
let entry = lock.artifact_for(SKILL).unwrap();
assert_eq!(entry.resolved_version, "2.0.0");
assert_eq!(entry.origin, runtime_origin("ses_test"));
let installed: Vec<_> = state
.installed_artifacts
.iter()
.filter(|a| a.name == SKILL)
.map(|a| a.version.as_str())
.collect();
assert_eq!(installed, vec!["2.0.0"]);
assert!(!state.removed_artifacts.contains(SKILL_TOOL));
assert!(dispatch(&state, SKILL_TOOL)
.await
.unwrap()
.contains("# second guidance"));
}
fn set_mode(path: &Path, mode: u32) {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(mode)).unwrap();
}
fn accepts_new_file(dir: &Path) -> bool {
let probe = dir.join(".write-probe");
let created = fs::write(&probe, b"").is_ok();
let _ = fs::remove_file(&probe);
created
}
#[tokio::test(flavor = "multi_thread")]
async fn a_failed_lock_write_changes_nothing_and_a_failed_deletion_still_refuses_calls() {
let project = Project::new();
project.publish_skill(SKILL, SKILL_TEXT);
let mut state = project.session(&[SKILL], &[]);
pull(&mut state, SKILL).unwrap();
let root = project.lock_path.parent().unwrap().to_path_buf();
let dir = project.installed(SKILL);
set_mode(&root, 0o555);
if accepts_new_file(&root) {
set_mode(&root, 0o755);
return;
}
let before = snapshot(&project, &state);
let write_failed = remove(&mut state, SKILL);
set_mode(&root, 0o755);
let err = write_failed.expect_err("the lock cannot be written");
assert!(err.starts_with("failed to write murmur.lock: "), "{err}");
assert_eq!(snapshot(&project, &state), before);
assert!(dispatch(&state, SKILL_TOOL)
.await
.unwrap()
.contains(SKILL_TEXT));
let parent = dir.parent().unwrap().to_path_buf();
set_mode(&parent, 0o555);
let delete_failed = remove(&mut state, SKILL);
set_mode(&parent, 0o755);
let err = delete_failed.expect_err("the directory cannot be deleted");
let prefix = format!(
"'{SKILL}' is removed from this session and from murmur.lock, but {} could not be \
deleted: ",
dir.display()
);
assert!(err.starts_with(&prefix), "{err}");
assert!(dir.is_dir());
assert!(!project.locked(SKILL));
assert!(state.installed_artifacts.iter().all(|a| a.name != SKILL));
assert_eq!(
dispatch(&state, SKILL_TOOL).await,
Err(called_after_removal(SKILL_TOOL))
);
}
}