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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
261 changes: 258 additions & 3 deletions apps/maple-agent/crates/maple-agent-updater/src/installer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ pub use linux_bundle::{
cleanup_current_linux_bundle, discover_linux_installation, validate_linux_bundle_archive,
};

// Keep the staging/receipt name stable for preparations made before the rebrand.
const APP_NAME: &str = "Maple Agent.app";
const WORK_APP_NAME: &str = "Maple Work.app";
const STAGING_PREFIX: &str = ".maple-agent-update-";
const STAGING_RANDOM_LENGTH: usize = 16;
const RECEIPT_NAME: &str = "installed.json";
Expand Down Expand Up @@ -222,7 +224,18 @@ fn install_lock_path(installed: &Path) -> Result<PathBuf> {
let parent = installed
.parent()
.ok_or(InstallError::Invalid("missing installation parent"))?;
let digest = hex::encode(Sha256::digest(installed.as_os_str().as_encoded_bytes()));
// Both macOS names share a lock: their staging directories share a parent,
// and startup cleanup must not sweep another name's live preparation.
// Use the legacy key so already-running older clients also serialize.
let lock_identity = if installed
.file_name()
.is_some_and(|name| name == WORK_APP_NAME)
{
parent.join(APP_NAME)
} else {
installed.to_owned()
};
let digest = hex::encode(Sha256::digest(lock_identity.as_os_str().as_encoded_bytes()));
Ok(parent.join(format!(".maple-agent-install-{digest}.lock")))
}

Expand Down Expand Up @@ -643,7 +656,9 @@ fn validate_destination(installed: &Path) -> Result<()> {
|| installed
.to_str()
.is_none_or(|path| path.chars().any(char::is_control))
|| installed.file_name().is_none_or(|n| n != APP_NAME)
|| installed
.file_name()
.is_none_or(|n| n != APP_NAME && n != WORK_APP_NAME)
|| installed
.components()
.any(|c| matches!(c, Component::ParentDir | Component::CurDir))
Expand Down Expand Up @@ -810,14 +825,24 @@ type ArchiveInventory = (BTreeMap<String, Node>, Option<Vec<u8>>);
/// attacker-declared extension size. Extensions are bounded and parsed here;
/// no ownership, xattrs, flags, hard links, sparse or special files are applied.
fn scan_archive(payload: &Path, output: Option<&Path>) -> Result<BTreeMap<String, Node>> {
scan_archive_root(payload, output, APP_NAME).map(|(nodes, _)| nodes)
scan_archive_with_alias(payload, output, APP_NAME, true).map(|(nodes, _)| nodes)
}

fn scan_archive_root(
payload: &Path,
output: Option<&Path>,
app_name: &str,
) -> Result<ArchiveInventory> {
scan_archive_with_alias(payload, output, app_name, false)
}

fn scan_archive_with_alias(
payload: &Path,
output: Option<&Path>,
app_name: &str,
allow_work_name: bool,
) -> Result<ArchiveInventory> {
let mut archive_root = None;
let mut descriptor = None;
let input = open_regular(payload, false, false)?;
let decoded = MultiGzDecoder::new(input);
Expand Down Expand Up @@ -895,6 +920,21 @@ fn scan_archive_root(
.remove("path")
.or(long_name.take())
.unwrap_or_else(|| header_path.to_owned());
// Admit exactly one known root, then normalize only that root to the
// legacy staging name. Mixed roots are rejected even for disjoint files.
let root = name.split('/').next().unwrap_or_default();
if root != app_name && !(allow_work_name && root == WORK_APP_NAME) {
return Err(InstallError::Invalid("unexpected application root"));
}
if archive_root
.as_deref()
.is_some_and(|previous| previous != root)
{
return Err(InstallError::Invalid("mixed application roots"));
}
archive_root = Some(root.to_owned());
let checked = archive_path(&name, kind.is_dir(), root)?;
let name = format!("{app_name}{}", &checked[root.len()..]);
let name = archive_path(&name, kind.is_dir(), app_name)?;
if !explicit.insert(name.clone()) {
return Err(InstallError::Invalid("duplicate archive path"));
Expand Down Expand Up @@ -1583,6 +1623,221 @@ mod tests {
}
}

fn named_update_fixture(
root: &Path,
installed_name: &str,
archive_name: &str,
) -> PreparedUpdate {
let mut update = update_fixture(root);
if installed_name != APP_NAME {
let destination = root.join(installed_name);
fs::rename(&update.installed_path, &destination).unwrap();
update.installed_path = destination.canonicalize().unwrap();
}
let original = fixture_members();
let names: Vec<_> = original
.iter()
.map(|member| {
let name = match member {
Member::Directory(name)
| Member::File(name, ..)
| Member::Link(name, ..)
| Member::Special(name, ..) => name,
};
name.replacen(APP_NAME, archive_name, 1)
})
.collect();
let members: Vec<_> = original
.iter()
.zip(&names)
.map(|(member, name)| match member {
Member::Directory(_) => Member::Directory(name),
Member::File(_, bytes, mode) => Member::File(name, bytes, *mode),
Member::Link(_, target) => Member::Link(name, target),
Member::Special(_, kind) => Member::Special(name, *kind),
})
.collect();
archive(&update.payload_path, &members);
let bytes = fs::read(&update.payload_path).unwrap();
let artifact = update
.release
.manifest
.artifacts
.get_mut("darwin-aarch64")
.unwrap();
artifact.size = bytes.len() as u64;
artifact.sha256 = hex::encode(Sha256::digest(&bytes));
update
}

#[test]
fn both_bundle_names_prepare_restore_revalidate_and_discard_both_archive_names() {
for installed_name in [APP_NAME, WORK_APP_NAME] {
for archive_name in [APP_NAME, WORK_APP_NAME] {
let root = tempfile::tempdir().unwrap();
let update = named_update_fixture(root.path(), installed_name, archive_name);
let verifier = Verifier::Test(accept_test_signature);
let prepared = prepare_inner(&update, verifier).unwrap();
assert_eq!(prepared.installed_path.file_name().unwrap(), installed_name);
assert_eq!(prepared.staged_path, prepared.staging_dir.join(APP_NAME));
let restored: InstallPreparation =
serde_json::from_slice(&serde_json::to_vec(&prepared).unwrap()).unwrap();
revalidate_inner(&restored, &update, verifier).unwrap();
let mut retargeted = restored.clone();
retargeted.staged_path = retargeted.staging_dir.join(WORK_APP_NAME);
assert!(revalidate_inner(&retargeted, &update, verifier).is_err());
fs::write(
prepared.staged_path.join("Contents/maple-agent"),
b"tampered",
)
.unwrap();
assert!(revalidate_inner(&restored, &update, verifier).is_err());
discard_preparation(&prepared).unwrap();
assert!(!prepared.staging_dir.exists());
assert!(update.installed_path.is_dir());
}
}
}

#[test]
fn bundle_aliases_share_the_legacy_install_lock_and_exclude_cleanup() {
let root = tempfile::tempdir().unwrap();
let legacy = root.path().join(APP_NAME);
let work = root.path().join(WORK_APP_NAME);
fs::create_dir(&legacy).unwrap();
fs::create_dir(&work).unwrap();
let legacy = legacy.canonicalize().unwrap();
let work = work.canonicalize().unwrap();
let old_digest = hex::encode(Sha256::digest(legacy.as_os_str().as_encoded_bytes()));
assert_eq!(
install_lock_path(&work).unwrap(),
legacy
.parent()
.unwrap()
.join(format!(".maple-agent-install-{old_digest}.lock"))
);
let guard = acquire_install_lock(&legacy).unwrap();
assert!(matches!(
acquire_install_lock(&work),
Err(InstallError::Busy)
));
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
assert!(matches!(
cleanup_after_startup(&work, "0.0.1", "1"),
Err(InstallError::Busy)
));
drop(guard);
let guard = acquire_install_lock(&work).unwrap();
assert!(matches!(
acquire_install_lock(&legacy),
Err(InstallError::Busy)
));
drop(guard);
}

#[test]
fn rebrand_does_not_admit_mixed_unknown_or_unsafe_archive_roots() {
let root = tempfile::tempdir().unwrap();
let payload = root.path().join("package");
for members in [
vec![
Member::Directory(APP_NAME),
Member::Directory(WORK_APP_NAME),
],
vec![
Member::Directory(WORK_APP_NAME),
Member::File("Maple Agent.app/Contents/file", b"x", 0o644),
],
vec![Member::Directory("Maple Research.app")],
vec![Member::Directory("Maple Work Dev.app")],
vec![
Member::Directory(WORK_APP_NAME),
Member::File("Maple Work.app/../outside", b"x", 0o644),
],
vec![
Member::Directory(WORK_APP_NAME),
Member::Link("Maple Work.app/escape", "../outside"),
],
] {
archive(&payload, &members);
assert!(scan_archive(&payload, None).is_err());
}
// The generic (Linux) scanner keeps its exact-root contract.
archive(&payload, &[Member::Directory(WORK_APP_NAME)]);
assert!(scan_archive_root(&payload, None, "maple-agent.app").is_err());
// The former exact macOS root contract cannot consume the future name.
assert!(scan_archive_root(&payload, None, APP_NAME).is_err());
}

#[test]
fn only_the_two_production_bundle_names_are_eligible_destinations() {
let root = tempfile::tempdir().unwrap();
for name in [
APP_NAME,
WORK_APP_NAME,
"Maple Work Dev.app",
"Maple Research.app",
] {
let bundle = root.path().join(name);
fs::create_dir(&bundle).unwrap();
let bundle = bundle.canonicalize().unwrap();
assert_eq!(
validate_destination(&bundle).is_ok(),
matches!(name, APP_NAME | WORK_APP_NAME)
);
}
let alias_parent = root.path().join("alias-parent");
fs::create_dir(&alias_parent).unwrap();
let alias = alias_parent.join(WORK_APP_NAME);
std::os::unix::fs::symlink(root.path().join(WORK_APP_NAME), &alias).unwrap();
assert!(validate_destination(&alias).is_err());
}

#[cfg(target_os = "macos")]
#[test]
fn both_bundle_names_swap_in_place_and_preserve_sibling_installations_and_backups() {
for installed_name in [APP_NAME, WORK_APP_NAME] {
for archive_name in [APP_NAME, WORK_APP_NAME] {
let root = tempfile::tempdir().unwrap();
let update = named_update_fixture(root.path(), installed_name, archive_name);
let sibling_name = if installed_name == APP_NAME {
WORK_APP_NAME
} else {
APP_NAME
};
let sibling = root.path().join(sibling_name);
fs::create_dir(&sibling).unwrap();
fs::write(sibling.join("untouched"), b"sibling").unwrap();
let verifier = Verifier::Test(accept_test_signature);
let prepared = prepare_inner(&update, verifier).unwrap();
let committed =
replace_inner(&prepared, &update, verifier, Synchronizer::Native).unwrap();
assert_eq!(committed.restart_path(), update.installed_path);
assert_eq!(
bundle_version(&committed.backup_path).unwrap(),
("0.0.1".into(), "1".into())
);
assert_eq!(
fs::read(committed.installed_path.join("Contents/maple-agent")).unwrap(),
b"verified package executable"
);
assert_eq!(fs::read(sibling.join("untouched")).unwrap(), b"sibling");
let restored: InstalledUpdate = serde_json::from_slice(
&fs::read(committed.staging_dir.join(RECEIPT_NAME)).unwrap(),
)
.unwrap();
assert_eq!(restored.installed_path, update.installed_path);
assert_eq!(restored.backup_path, committed.backup_path);
assert!(discard_preparation(&prepared).is_err());
assert!(
restored.checkpoint_cleanup().is_err(),
"unsigned fixture cannot authorize backup deletion"
);
assert!(restored.backup_path.is_dir());
}
}
}

fn accept_test_signature(_: &Path, _: &str, _: &str) -> Result<()> {
Ok(())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ pub(super) fn validate_destination(installed: &Path) -> Result<()> {
})
{
return Err(InstallError::NotifyOnly(
"run a user-owned installed Maple Agent Linux bundle",
"run a user-owned installed Maple Work Linux bundle",
));
}
let metadata = fs::symlink_metadata(installed)?;
Expand Down
28 changes: 28 additions & 0 deletions apps/maple-agent/docs/updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,34 @@ reopen use the validated persistent `AppRun`; the bounded SQL integrity child
executes the validated loader/payload directly to preserve parent ownership and
private stdin. After replacement, restart targets the committed bundle's `AppRun`.

## Maple Work filename bridge

Display branding is Maple Work; current macOS packages still contain
`Maple Agent.app`. The installer accepts either `Maple Agent.app` or
`Maple Work.app` as the installed Prod bundle and exactly one of those roots
inside a macOS update archive. Dev names and arbitrary application names are
not admitted. Bundle ID, signing identity, signed metadata, payload hashes,
version checks and the existing extraction limits remain mandatory.

The archive root is normalized to the legacy staging name inside the private
staging directory. Replacement and relaunch preserve the existing installation
path, and saved preparations/receipts keep their existing format. The two names
in one parent directory share the legacy install lock, preventing concurrent
preparation or startup cleanup from crossing names. If both apps exist, updating
one leaves the other intact; this bridge does not merge or remove installations.

This is compatibility preparation, not a filename migration. Do not change the
root published on the existing macOS feed yet: older clients require
`Maple Agent.app` and can skip the bridge release because they fetch one latest
offer. A later rename needs a separately reviewed delivery plan that retains a
legacy-root upgrade path for those clients (or an explicit manual-upgrade
policy), coordinates package generation/verification and tests signed installed
updates. Automatically moving an existing app to a new path additionally needs
collision, interruption, relaunch and recovery handling. This change neither
renames existing installations nor changes feed/schema, release paths or data
locations. Linux retains its existing `maple-agent.app` installation identity;
its visible branding and updater guidance use Maple Work.

## Wire and trust contract

The response is an envelope with two fields: `manifest` is the exact JSON string
Expand Down
Loading