From 092a7aa842e83e91f0e025a90ccfb4175bd5db6a Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Thu, 9 Apr 2026 09:22:20 +0200 Subject: [PATCH 1/4] Initial support for using Apple containers for isolation on MacOS --- Cargo.lock | 1 + README.md | 24 + config.toml | 12 +- lib/Cargo.toml | 1 + lib/src/lib.rs | 33 +- lib/src/manager.rs | 13 +- lib/src/services/combined.rs | 544 +++++------------- lib/src/services/config.rs | 139 ++++- lib/src/services/mod.rs | 5 +- lib/src/services/opencode_client_service.rs | 154 +++-- lib/src/services/resource_usage_service.rs | 105 ++-- lib/src/services/root_session_service.rs | 99 ++-- lib/src/services/transient_storage.rs | 146 ++++- lib/src/services/usage_aggregation_service.rs | 31 +- remote/src/orchestration.rs | 13 +- remote/tests/docker_remote_integration.rs | 94 ++- tui/src/app.rs | 10 +- tui/src/ops.rs | 66 +++ tui/src/tests.rs | 56 +- 19 files changed, 934 insertions(+), 612 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7f47cf5..bb8f305 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1753,6 +1753,7 @@ dependencies = [ name = "multicode-lib" version = "0.1.0" dependencies = [ + "base64", "diesel", "diesel_migrations", "libsqlite3-sys", diff --git a/README.md b/README.md index 1650e66..064b936 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,30 @@ Isolation is implemented using `systemd-run` (for resource constraints) and [`bwrap`](https://github.com/containers/bubblewrap) (for read/write isolation). These tools are **Linux only**, so *multicode* will not work on other operating systems. +On newer Apple Silicon Macs, there is also an experimental Apple `container` runtime backend. It +reuses the existing `[isolation]` configuration for readable, writable, isolated, and `tmpfs` +paths, and maps CPU / memory limits onto container allocation settings: + +```toml +[runtime] +backend = "apple-container" +image = "ghcr.io/example/multicode-java25:latest" + +[isolation] +writable = ["~/.gradle", "~/.m2/repository", "~/.config/gh"] +readable = ["~/.config/opencode", "~/.local/share/opencode/auth.json"] +isolated = ["~/.local/share/opencode", "~/.local/state/opencode"] +tmpfs = ["/tmp"] +inherit-env = ["HOME", "PATH", "XDG_RUNTIME_DIR", "GITHUB_MCP_TOKEN"] +memory-max = "16 GiB" +cpu = "300%" +``` + +Mounting `~/.config/opencode` read-only lets the container see the same profiles, models, +skills, and other OpenCode configuration as the host. This is useful if you manage local +profiles with tools like `ocp`. Keep `~/.local/share/opencode` and `~/.local/state/opencode` +isolated so session state remains per-workspace. + ## Git / GitHub integration With the GitHub integration you can see progress at a glance in the overview screen, and navigate to the issue or PR diff --git a/config.toml b/config.toml index e5d3792..b51cd91 100644 --- a/config.toml +++ b/config.toml @@ -2,6 +2,11 @@ workspace-directory = "~/dev/agent-work" opencode = ["opencode-cli", "opencode"] # todo: find a solution that isn't bound to TUI lifecycle +[runtime] +backend = "apple-container" +# Local Apple container image. It should contain Java 25, git, gh, and opencode. +image = "multicode-java25:latest" + [github] #token = {command = "gh auth token"} token = {env = "GITHUB_MCP_TOKEN"} @@ -30,6 +35,7 @@ isolated = [ "~/.local/state/opencode", ] readable = [ + "~/.config/opencode", "~/.local/share/opencode/auth.json", ] tmpfs = [ @@ -38,8 +44,8 @@ tmpfs = [ ] inherit-env = [ "XDG_RUNTIME_DIR", - "DISPLAY", "HOME", + "PATH", "LANG", "TERM", "COLORTERM", @@ -51,9 +57,9 @@ cpu = "300%" [handler] -review = "/usr/bin/smerge ." +review = "/usr/bin/open ." review-pty = false -web = "/usr/bin/firefox {}" +web = "/usr/bin/open {}" [[tool]] type = "exec" diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 340c363..65c43a7 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -25,6 +25,7 @@ tracing = "0" tracing-subscriber = { version = "0", features = ["fmt", "ansi"] } shell-words = "1" size = "0" +base64 = "0.22" [build-dependencies] openapiv3 = "2" diff --git a/lib/src/lib.rs b/lib/src/lib.rs index f8b70b4..3291d58 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -17,7 +17,7 @@ pub use remote_action::{ pub use services::root_session_service::RootSessionStatus; pub use services::workspace_archive::WorkspaceArchiveFormat; -use std::{fmt, sync::Arc, time::SystemTime}; +use std::{collections::BTreeMap, fmt, sync::Arc, time::SystemTime}; use serde::{Deserialize, Serialize}; @@ -90,10 +90,39 @@ impl Default for PersistentWorkspaceSnapshot { /// Workspace metadata that is saved in transient storage (`/run`) and does not survive a reboot. /// This is useful for process metadata. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum RuntimeBackend { + #[default] + LinuxSystemdBwrap, + AppleContainer, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuntimeHandleSnapshot { + #[serde(default)] + pub backend: RuntimeBackend, + #[serde(default, alias = "unit")] + pub id: String, + #[serde(default)] + pub metadata: BTreeMap, +} + +impl Default for RuntimeHandleSnapshot { + fn default() -> Self { + Self { + backend: RuntimeBackend::default(), + id: String::new(), + metadata: BTreeMap::new(), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TransientWorkspaceSnapshot { pub uri: String, - pub unit: String, + #[serde(flatten)] + pub runtime: RuntimeHandleSnapshot, } /// Holder for the HTTP connection to the opencode server. diff --git a/lib/src/manager.rs b/lib/src/manager.rs index 7bf12e1..41e401f 100644 --- a/lib/src/manager.rs +++ b/lib/src/manager.rs @@ -111,7 +111,10 @@ impl WorkspaceManager { #[cfg(test)] mod tests { use super::*; - use crate::{PersistentWorkspaceSnapshot, TransientWorkspaceSnapshot}; + use crate::{ + PersistentWorkspaceSnapshot, RuntimeBackend, RuntimeHandleSnapshot, + TransientWorkspaceSnapshot, + }; #[test] fn add_notifies_workspace_set_watch() { @@ -190,7 +193,11 @@ mod tests { snapshot.persistent.description = "incrementally updated".to_string(); snapshot.transient = Some(TransientWorkspaceSnapshot { uri: "http://opencode:secret@127.0.0.1:31337/".to_string(), - unit: "run-u42.service".to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: "run-u42.service".to_string(), + metadata: Default::default(), + }, }); true }); @@ -199,7 +206,7 @@ mod tests { let updated = workspace_rx.borrow_and_update().clone(); assert_eq!(updated.persistent.description, "incrementally updated"); assert_eq!( - updated.transient.as_ref().map(|t| t.unit.as_str()), + updated.transient.as_ref().map(|t| t.runtime.id.as_str()), Some("run-u42.service") ); assert!(!workspace_set_rx.has_changed().expect("watch still open")); diff --git a/lib/src/services/combined.rs b/lib/src/services/combined.rs index 626fd9e..a0815a5 100644 --- a/lib/src/services/combined.rs +++ b/lib/src/services/combined.rs @@ -1,5 +1,4 @@ use std::{ - env, io::ErrorKind, path::{Path, PathBuf}, process::{ExitStatus, Stdio}, @@ -8,206 +7,34 @@ use std::{ }; use tokio::process::Command; -use uuid::Uuid; - -fn shell_escape_arg(arg: &str) -> String { - if arg.is_empty() { - "''".to_string() - } else if arg - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | ':' | '_' | '-' | '.' | '=')) - { - arg.to_string() - } else { - format!("'{}'", arg.replace('\'', "'\\''")) - } -} - -fn format_command_line(program: &str, args: &[String]) -> String { - std::iter::once(program) - .chain(args.iter().map(String::as_str)) - .map(shell_escape_arg) - .collect::>() - .join(" ") -} - -fn append_systemd_run_inherit_env(args: &mut Vec, env: &[(String, String)]) { - for (name, _) in env { - args.push("--setenv".to_string()); - args.push(name.clone()); - } -} #[derive(Debug, Clone, PartialEq, Eq)] pub struct SpawnCommand { + pub program: String, pub args: Vec, pub inherited_env: Vec<(String, String)>, } use crate::{ - TransientWorkspaceSnapshot, WorkspaceArchiveFormat, WorkspaceManager, WorkspaceManagerError, - database::Database, logging, + WorkspaceArchiveFormat, WorkspaceManager, WorkspaceManagerError, database::Database, logging, }; use super::{ GithubStatusService, GithubStatusServiceError, WorkspaceDirectoryError, config::{ - AddedSkillMount, Config, ExpandedIsolationConfig, expand_shell_path, path_looks_like_file, + AddedSkillMount, Config, ExpandedIsolationConfig, expand_shell_path, inherited_env_value, read_config, resolve_opencode_command, validate_handler_config, validate_remote_config, validate_tool_config_entries, validate_workspace_key, }, multicode_metadata_service, opencode_client_service, persistent_storage, - resource_usage_service, root_session_service, transient_storage, usage_aggregation_service, + resource_usage_service, root_session_service, + runtime::WorkspaceRuntime, + runtime_reconciliation_service::runtime_reconciliation_service, + transient_storage, usage_aggregation_service, workspace_archive::ArchiveWorkspaceEntry, workspace_directory, }; -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -enum MountKind { - Readable, - Writable, - Isolated, - Tmpfs, -} - -#[derive(Debug, Clone)] -struct MountSpec { - target: PathBuf, - source: Option, - kind: MountKind, - is_file: bool, -} - -impl MountSpec { - fn new(target: PathBuf, source: Option, kind: MountKind) -> Self { - let is_file = match source.as_ref() { - Some(source) => std::fs::metadata(source) - .map(|metadata| metadata.is_file()) - .unwrap_or(false), - None => std::fs::metadata(&target) - .map(|metadata| metadata.is_file()) - .unwrap_or_else(|_| path_looks_like_file(&target)), - }; - Self { - target, - source, - kind, - is_file, - } - } - - fn depth(&self) -> usize { - self.target.components().count() - } - - fn resolve_backing_path(path: &Path, prior_mounts: &[ResolvedMountSpec]) -> PathBuf { - for prior_mount in prior_mounts.iter().rev() { - if path == prior_mount.mount.target || path.starts_with(&prior_mount.mount.target) { - let relative = path - .strip_prefix(&prior_mount.mount.target) - .expect("path should be under prior mount target"); - return prior_mount.effective_source.join(relative); - } - } - path.to_path_buf() - } - - fn resolve_effective(&self, prior_mounts: &[ResolvedMountSpec]) -> ResolvedMountSpec { - let effective_target = Self::resolve_backing_path(&self.target, prior_mounts); - let effective_source = match self.kind { - MountKind::Isolated => self - .source - .as_ref() - .map(|source| Self::resolve_backing_path(source, prior_mounts)) - .unwrap_or_else(|| effective_target.clone()), - MountKind::Readable | MountKind::Writable => { - self.source.clone().unwrap_or_else(|| self.target.clone()) - } - MountKind::Tmpfs => effective_target.clone(), - }; - ResolvedMountSpec { - mount: self.clone(), - effective_target, - effective_source, - } - } -} - -#[derive(Debug, Clone)] -struct ResolvedMountSpec { - mount: MountSpec, - effective_target: PathBuf, - effective_source: PathBuf, -} - -impl ResolvedMountSpec { - async fn prepare_source_node(&self, owns_node: bool) -> Result<(), CombinedServiceError> { - self.prepare_node( - &self.effective_source, - owns_node, - self.mount - .source - .as_ref() - .filter(|original| *original != &self.effective_source), - ) - .await - } - - async fn prepare_target_node(&self, owns_node: bool) -> Result<(), CombinedServiceError> { - let should_materialize = owns_node - && (!self.mount.is_file - || matches!(self.mount.kind, MountKind::Writable | MountKind::Isolated)); - self.prepare_node(&self.effective_target, should_materialize, None) - .await - } - - async fn prepare_node( - &self, - path: &Path, - materialize_node: bool, - seed_file: Option<&PathBuf>, - ) -> Result<(), CombinedServiceError> { - if self.mount.is_file { - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - if materialize_node && tokio::fs::metadata(path).await.is_err() { - if let Some(seed_file) = seed_file { - if tokio::fs::metadata(seed_file).await.is_ok() { - tokio::fs::copy(seed_file, path).await?; - return Ok(()); - } - } - tokio::fs::File::create(path).await?; - } - } else if materialize_node { - tokio::fs::create_dir_all(path).await?; - } else if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - Ok(()) - } - - fn append_args(&self, args: &mut Vec) { - match self.mount.kind { - MountKind::Readable => { - args.push("--ro-bind".to_string()); - args.push(self.effective_source.to_string_lossy().into_owned()); - args.push(self.mount.target.to_string_lossy().into_owned()); - } - MountKind::Writable | MountKind::Isolated => { - args.push("--bind".to_string()); - args.push(self.effective_source.to_string_lossy().into_owned()); - args.push(self.mount.target.to_string_lossy().into_owned()); - } - MountKind::Tmpfs => { - args.push("--tmpfs".to_string()); - args.push(self.mount.target.to_string_lossy().into_owned()); - } - } - } -} - #[derive(Debug, Clone)] pub struct CombinedService { pub config: Config, @@ -217,6 +44,7 @@ pub struct CombinedService { workspace_directory_path: PathBuf, expanded_isolation: ExpandedIsolationConfig, opencode_command: String, + runtime: WorkspaceRuntime, github_git_credentials_env: Option, } @@ -247,6 +75,8 @@ impl CombinedService { validate_handler_config(&config.handler)?; validate_remote_config(config.remote.as_ref())?; let opencode_command = resolve_opencode_command(&config.opencode)?; + let container_opencode_command = + resolve_container_opencode_command(config.runtime.backend, &config.opencode); let workspace_directory_path = expand_shell_path(&config.workspace_directory)?; if let Err(err) = logging::enable_workspace_file_logging(&workspace_directory_path).await { logging::log_file_enable_failed( @@ -273,6 +103,13 @@ impl CombinedService { GithubStatusService::new(database.clone(), config.github.token.clone()).await?; let github_git_credentials_env = github_git_credentials_env_from_config(&config, &github_status_service).await?; + let runtime = WorkspaceRuntime::new( + config.runtime.clone(), + workspace_directory_path.clone(), + expanded_isolation.clone(), + opencode_command.clone(), + container_opencode_command, + ); let persistent_path = workspace_directory_path .join(".multicode") @@ -287,6 +124,7 @@ impl CombinedService { workspace_directory_path.clone(), ); spawn_transient_storage(manager.clone(), transient_link); + spawn_runtime_reconciliation_service(manager.clone(), runtime.clone()); spawn_opencode_client_service(manager.clone()); spawn_root_session_service(manager.clone()); spawn_multicode_metadata_service(manager.clone()); @@ -301,6 +139,7 @@ impl CombinedService { workspace_directory_path, expanded_isolation, opencode_command, + runtime, github_git_credentials_env, }) } @@ -342,43 +181,20 @@ impl CombinedService { let workspace_path = self.workspace_directory_path.join(&key); tokio::fs::create_dir_all(&workspace_path).await?; - let password = generate_random_password(); - let port = pick_random_free_port().await?; - let unit = generate_transient_unit_name(); - let args = self - .build_systemd_bwrap_command(&key, &password, port, &unit) + let inherited_env = self + .sandbox_env_pairs(Vec::<(String, String)>::new()) .await?; + let start = self.runtime.start_server(&key, &inherited_env).await?; tracing::info!( workspace_key = %key, - command = %format_command_line("systemd-run", &args.args), - "starting application via systemd-run opencode serve" + backend = ?self.config.runtime.backend, + runtime_id = %start.transient.runtime.id, + "started workspace runtime" ); - let mut command = Command::new("systemd-run"); - command - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .args(&args.args); - for (name, value) in &args.inherited_env { - command.env(name, value); - } - let output = command.output().await?; - - if !output.status.success() { - return Err(CombinedServiceError::StartWorkspaceFailed { - status: output.status.code(), - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), - }); - } - - let uri = format!("http://opencode:{password}@127.0.0.1:{port}/"); - let transient = TransientWorkspaceSnapshot { - uri, - unit: unit.clone(), - }; let mut replaced = false; workspace.update(|snapshot| { if snapshot.transient.is_none() { - snapshot.transient = Some(transient.clone()); + snapshot.transient = Some(start.transient.clone()); replaced = true; true } else { @@ -387,7 +203,7 @@ impl CombinedService { }); if !replaced { - stop_systemd_unit(&unit).await?; + self.runtime.stop_server(&start.transient.runtime).await?; return Err(CombinedServiceError::TransientSnapshotAlreadyPresent( key.to_string(), )); @@ -400,14 +216,14 @@ impl CombinedService { let key = validate_workspace_key(key)?; let workspace = self.manager.get_workspace(&key)?; let workspace_rx = workspace.subscribe(); - let unit = workspace_rx + let runtime_handle = workspace_rx .borrow() .transient .as_ref() - .map(|transient| transient.unit.clone()) + .map(|transient| transient.runtime.clone()) .ok_or_else(|| CombinedServiceError::TransientSnapshotMissing(key.clone()))?; - stop_systemd_unit(&unit).await?; + self.runtime.stop_server(&runtime_handle).await?; workspace.update(|snapshot| { if snapshot.transient.is_some() { snapshot.transient = None; @@ -453,27 +269,12 @@ impl CombinedService { let workspace_path = self.workspace_directory_path.join(&key); tokio::fs::create_dir_all(&workspace_path).await?; - let unit = generate_transient_unit_name(); - let mut args = vec![ - "--user".to_string(), - "--wait".to_string(), - "--collect".to_string(), - "--pty".to_string(), - ]; let inherited_env = self .sandbox_env_pairs(Vec::<(String, String)>::new()) .await?; - append_systemd_run_inherit_env(&mut args, &inherited_env); - args.push("--unit".to_string()); - args.push(unit); - self.append_systemd_limits(&mut args); - self.append_bwrap_sandbox_args(&mut args, &key).await?; - args.extend(command); - - Ok(SpawnCommand { - args, - inherited_env, - }) + self.runtime + .build_pty_command(&key, &inherited_env, command) + .await } pub async fn archive_workspace( @@ -624,6 +425,7 @@ impl CombinedService { &self.opencode_command } + #[cfg_attr(not(test), allow(dead_code))] async fn build_systemd_bwrap_command( &self, key: &str, @@ -631,50 +433,12 @@ impl CombinedService { port: u16, unit: &str, ) -> Result { - let mut args = vec!["--user".to_string(), "--no-block".to_string()]; let inherited_env = self - .sandbox_env_pairs(vec![ - ( - "OPENCODE_SERVER_USERNAME".to_string(), - "opencode".to_string(), - ), - ("OPENCODE_SERVER_PASSWORD".to_string(), password.to_string()), - ]) + .sandbox_env_pairs(Vec::<(String, String)>::new()) .await?; - append_systemd_run_inherit_env(&mut args, &inherited_env); - args.push("--unit".to_string()); - args.push(unit.to_string()); - self.append_systemd_limits(&mut args); - - self.append_bwrap_sandbox_args(&mut args, key).await?; - args.push(self.opencode_command.clone()); - args.push("serve".to_string()); - args.push("--hostname".to_string()); - args.push("127.0.0.1".to_string()); - args.push("--port".to_string()); - args.push(port.to_string()); - - Ok(SpawnCommand { - args, - inherited_env, - }) - } - - fn append_systemd_limits(&self, args: &mut Vec) { - if let Some(memory_high_bytes) = self.expanded_isolation.memory_high_bytes { - args.push("-p".to_string()); - args.push(format!("MemoryHigh={memory_high_bytes}")); - } - if let Some(memory_max_bytes) = self.expanded_isolation.memory_max_bytes { - args.push("-p".to_string()); - args.push(format!("MemoryMax={memory_max_bytes}")); - args.push("-p".to_string()); - args.push("MemorySwapMax=0".to_string()); - } - if let Some(cpu) = &self.expanded_isolation.cpu { - args.push("-p".to_string()); - args.push(format!("CPUQuota={cpu}")); - } + self.runtime + .build_linux_start_command(key, password, port, unit, &inherited_env) + .await } async fn sandbox_env_pairs( @@ -688,118 +452,12 @@ impl CombinedService { .inherit_env .iter() .filter_map(|env_name| { - env::var(env_name) - .ok() - .map(|env_value| (env_name.clone(), env_value)) + inherited_env_value(env_name).map(|value| (env_name.clone(), value)) }), ); Ok(env) } - async fn append_bwrap_sandbox_args( - &self, - args: &mut Vec, - key: &str, - ) -> Result<(), CombinedServiceError> { - let workspace_path = self.workspace_directory_path.join(key); - let workspace_path_str = workspace_path.to_string_lossy().into_owned(); - - args.push("bwrap".to_string()); - args.push("--chdir".to_string()); - args.push(workspace_path_str.clone()); - - args.push("--ro-bind".to_string()); - args.push("/".to_string()); - args.push("/".to_string()); - - let mut mount_specs = Vec::new(); - mount_specs.extend( - self.expanded_isolation - .readable - .iter() - .cloned() - .map(|path| MountSpec::new(path, None, MountKind::Readable)), - ); - mount_specs.extend( - self.expanded_isolation - .writable - .iter() - .cloned() - .map(|path| MountSpec::new(path.clone(), Some(path), MountKind::Writable)), - ); - mount_specs.push(MountSpec::new( - workspace_path.clone(), - Some(workspace_path.clone()), - MountKind::Writable, - )); - mount_specs.extend( - self.expanded_isolation - .isolated - .iter() - .cloned() - .map(|path| { - let source = self.isolated_storage_path(key, &path); - MountSpec::new(path.clone(), Some(source), MountKind::Isolated) - }), - ); - mount_specs.extend( - self.expanded_isolation - .tmpfs - .iter() - .cloned() - .map(|path| MountSpec::new(path, None, MountKind::Tmpfs)), - ); - mount_specs.extend( - self.expanded_isolation - .added_skills - .iter() - .cloned() - .map(|mount| MountSpec::new(mount.target, Some(mount.source), MountKind::Readable)), - ); - mount_specs.sort_by(|a, b| { - a.depth() - .cmp(&b.depth()) - .then_with(|| a.target.cmp(&b.target)) - .then_with(|| a.kind.cmp(&b.kind)) - }); - - let mut resolved_mounts = Vec::with_capacity(mount_specs.len()); - for (index, mount_spec) in mount_specs.iter().enumerate() { - let resolved_mount = mount_spec.resolve_effective(&resolved_mounts); - let owns_node = !mount_specs.iter().skip(index + 1).any(|other| { - other.target.starts_with(&mount_spec.target) && other.target != mount_spec.target - }); - let owns_source_node = owns_node - || (mount_spec.is_file - && mount_spec - .source - .as_ref() - .is_some_and(|source| source != &resolved_mount.effective_source)); - resolved_mount.prepare_source_node(owns_source_node).await?; - resolved_mount.prepare_target_node(owns_node).await?; - resolved_mounts.push(resolved_mount); - } - - for resolved_mount in resolved_mounts { - resolved_mount.append_args(args); - } - - args.push("--proc".to_string()); - args.push("/proc".to_string()); - args.push("--dev".to_string()); - args.push("/dev".to_string()); - args.push("--die-with-parent".to_string()); - - Ok(()) - } - - fn isolated_storage_path(&self, key: &str, target: &Path) -> PathBuf { - let relative = target - .strip_prefix("/") - .expect("isolated path is validated as absolute"); - self.isolate_path_for_key(key).join(relative) - } - fn isolate_path_for_key(&self, key: &str) -> PathBuf { self.workspace_directory_path .join(".multicode") @@ -973,6 +631,50 @@ impl CombinedService { } } +fn resolve_container_opencode_command( + backend: crate::RuntimeBackend, + candidates: &[String], +) -> String { + if backend == crate::RuntimeBackend::AppleContainer { + return candidates + .iter() + .filter_map(|candidate| { + let candidate = candidate.trim(); + if candidate.is_empty() { + return None; + } + let name = Path::new(candidate) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(candidate); + if name == "opencode" { + Some("opencode".to_string()) + } else { + None + } + }) + .next() + .unwrap_or_else(|| "opencode".to_string()); + } + + candidates + .iter() + .find_map(|candidate| { + let candidate = candidate.trim(); + if candidate.is_empty() { + return None; + } + Some( + Path::new(candidate) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(candidate) + .to_string(), + ) + }) + .unwrap_or_else(|| "opencode".to_string()) +} + async fn github_git_credentials_env_from_config( config: &Config, github_status_service: &GithubStatusService, @@ -1041,7 +743,12 @@ pub enum CombinedServiceError { field: String, message: String, }, + InvalidRuntimeConfig { + field: String, + message: String, + }, InvalidToolExecution(String), + UnsupportedRuntimeBackend(String), WorkspaceArchived(String), WorkspaceNotArchived(String), ArchiveWorkspaceRunning(String), @@ -1095,38 +802,7 @@ impl From for CombinedServiceError { } } -fn generate_random_password() -> String { - Uuid::new_v4().as_simple().to_string() -} - -fn generate_transient_unit_name() -> String { - format!("multicode-{}.service", Uuid::new_v4().as_simple()) -} - -async fn pick_random_free_port() -> Result { - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await?; - let port = listener.local_addr()?.port(); - drop(listener); - Ok(port) -} - -async fn stop_systemd_unit(unit: &str) -> Result<(), CombinedServiceError> { - let args = stop_systemd_args(unit); - let output = Command::new("systemctl") - .args(args) - .stdin(Stdio::null()) - .output() - .await?; - if output.status.success() { - Ok(()) - } else { - Err(CombinedServiceError::StopWorkspaceFailed { - status: output.status.code(), - stderr: String::from_utf8_lossy(&output.stderr).into_owned(), - }) - } -} - +#[cfg_attr(not(test), allow(dead_code))] fn stop_systemd_args(unit: &str) -> Vec { vec![ "--user".to_string(), @@ -1156,6 +832,14 @@ fn spawn_transient_storage(manager: Arc, transient_link: PathB }); } +fn spawn_runtime_reconciliation_service(manager: Arc, runtime: WorkspaceRuntime) { + tokio::spawn(async move { + if let Err(err) = runtime_reconciliation_service(manager, runtime).await { + tracing::error!(error = ?err, "runtime reconciliation service exited with error"); + } + }); +} + fn spawn_opencode_client_service(manager: Arc) { tokio::spawn(async move { if let Err(err) = opencode_client_service(manager).await { @@ -1199,7 +883,10 @@ fn spawn_resource_usage_service(manager: Arc) { #[cfg(test)] mod tests { use super::*; - use crate::services::{GithubTokenConfig, ToolType}; + use crate::services::{ + GithubTokenConfig, ToolType, + runtime::{MountKind, MountSpec}, + }; use crate::test_support::ENV_VAR_LOCK; use diesel::{QueryableByName, RunQueryDsl, sql_query, sqlite::SqliteConnection}; use std::os::unix::fs::PermissionsExt; @@ -1317,6 +1004,35 @@ token = { env = "GITHUB_TOKEN" } ); } + #[test] + fn resolve_container_opencode_command_prefers_opencode_for_apple_backend() { + assert_eq!( + resolve_container_opencode_command( + crate::RuntimeBackend::AppleContainer, + &["opencode-cli".to_string(), "opencode".to_string()] + ), + "opencode" + ); + assert_eq!( + resolve_container_opencode_command( + crate::RuntimeBackend::AppleContainer, + &["/opt/homebrew/bin/opencode-cli".to_string()] + ), + "opencode" + ); + } + + #[test] + fn resolve_container_opencode_command_keeps_first_candidate_for_linux_backend() { + assert_eq!( + resolve_container_opencode_command( + crate::RuntimeBackend::LinuxSystemdBwrap, + &["opencode-cli".to_string(), "opencode".to_string()] + ), + "opencode-cli" + ); + } + #[test] fn config_parses_github_populate_git_credentials_flag() { let config: Config = toml::from_str( diff --git a/lib/src/services/config.rs b/lib/src/services/config.rs index 2527899..cc5cb74 100644 --- a/lib/src/services/config.rs +++ b/lib/src/services/config.rs @@ -9,12 +9,15 @@ use serde::{Deserialize, Serialize}; use size::Size; use super::CombinedServiceError; +use crate::RuntimeBackend; #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] pub struct Config { pub workspace_directory: String, pub isolation: IsolationConfig, + #[serde(default)] + pub runtime: RuntimeConfig, #[serde(default = "default_opencode_commands")] pub opencode: Vec, #[serde(default)] @@ -27,6 +30,15 @@ pub struct Config { pub github: GithubConfig, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "kebab-case")] +pub struct RuntimeConfig { + #[serde(default)] + pub backend: RuntimeBackend, + #[serde(default)] + pub image: Option, +} + #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Default)] #[serde(rename_all = "kebab-case")] pub struct GithubConfig { @@ -315,11 +327,44 @@ pub(super) fn validate_workspace_key(key: &str) -> Result Result { - let expanded = shellexpand::full(value) - .map_err(|err| CombinedServiceError::ShellExpand(err.to_string()))?; + let expanded = shellexpand::full_with_context( + value, + || env::var("HOME").ok(), + |name| match env::var(name) { + Ok(value) => Ok(Some(value)), + Err(env::VarError::NotPresent) => Ok(synthesized_env_value(name)), + Err(err) => Err(err.to_string()), + }, + ) + .map_err(|err| CombinedServiceError::ShellExpand(err.to_string()))?; Ok(PathBuf::from(expanded.into_owned())) } +pub(super) fn inherited_env_value(name: &str) -> Option { + env::var(name).ok().or_else(|| synthesized_env_value(name)) +} + +pub(super) fn synthesized_env_value(name: &str) -> Option { + match name { + "XDG_RUNTIME_DIR" => { + synthesized_xdg_runtime_dir().map(|path| path.to_string_lossy().into_owned()) + } + _ => None, + } +} + +pub(super) fn synthesized_xdg_runtime_dir() -> Option { + #[cfg(target_os = "macos")] + { + Some(env::temp_dir().join("multicode-runtime")) + } + + #[cfg(not(target_os = "macos"))] + { + None + } +} + fn expand_isolation_paths( paths: &[String], field: &str, @@ -557,3 +602,93 @@ fn validate_handler_template( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + ffi::OsString, + sync::Mutex, + time::{SystemTime, UNIX_EPOCH}, + }; + + static ENV_VAR_LOCK: Mutex<()> = Mutex::new(()); + + struct EnvVarGuard { + key: &'static str, + old_value: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: impl AsRef) -> Self { + let old_value = env::var_os(key); + unsafe { + env::set_var(key, value); + } + Self { key, old_value } + } + + fn remove(key: &'static str) -> Self { + let old_value = env::var_os(key); + unsafe { + env::remove_var(key); + } + Self { key, old_value } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(value) = &self.old_value { + unsafe { + env::set_var(self.key, value); + } + } else { + unsafe { + env::remove_var(self.key); + } + } + } + } + + #[test] + fn expand_shell_path_expands_existing_environment_variables() { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos(); + let runtime_dir = env::temp_dir().join(format!("multicode-config-test-{unique}")); + let _guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let path = + expand_shell_path("$XDG_RUNTIME_DIR/opencode").expect("runtime dir should expand"); + + assert_eq!(path, runtime_dir.join("opencode")); + } + + #[cfg(target_os = "macos")] + #[test] + fn expand_shell_path_synthesizes_xdg_runtime_dir_on_macos() { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let _guard = EnvVarGuard::remove("XDG_RUNTIME_DIR"); + + let path = + expand_shell_path("$XDG_RUNTIME_DIR/opencode").expect("runtime dir should expand"); + + assert_eq!( + path, + synthesized_xdg_runtime_dir() + .expect("macOS should synthesize XDG runtime dir") + .join("opencode") + ); + assert_eq!( + inherited_env_value("XDG_RUNTIME_DIR"), + synthesized_xdg_runtime_dir().map(|path| path.to_string_lossy().into_owned()) + ); + } +} diff --git a/lib/src/services/mod.rs b/lib/src/services/mod.rs index c1c24f2..54ef05c 100644 --- a/lib/src/services/mod.rs +++ b/lib/src/services/mod.rs @@ -6,6 +6,8 @@ pub mod opencode_client_service; pub mod persistent_storage; pub mod resource_usage_service; pub mod root_session_service; +pub mod runtime; +pub(crate) mod runtime_reconciliation_service; pub mod transient_storage; pub mod usage_aggregation_service; pub mod workspace_archive; @@ -16,7 +18,8 @@ pub(crate) mod workspace_watch; pub use crate::database::{Database, DatabaseError}; pub use combined::{CombinedService, CombinedServiceError}; pub use config::{ - Config, GithubTokenConfig, HandlerConfig, ToolConfig, ToolType, parse_optional_size_bytes, + Config, GithubTokenConfig, HandlerConfig, RuntimeConfig, ToolConfig, ToolType, + parse_optional_size_bytes, }; pub use github_status_service::{ GithubIssueState, GithubIssueStatus, GithubPrBuildState, GithubPrReviewState, GithubPrState, diff --git a/lib/src/services/opencode_client_service.rs b/lib/src/services/opencode_client_service.rs index 8d079e4..4bda479 100644 --- a/lib/src/services/opencode_client_service.rs +++ b/lib/src/services/opencode_client_service.rs @@ -7,14 +7,19 @@ use std::{ time::Duration, }; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; use tokio::{ process::Command, sync::{broadcast, watch}, task::JoinHandle, }; use tokio_stream::StreamExt; +use url::Url; -use super::workspace_watch::monitor_workspace_snapshots; +use super::{ + runtime::{RuntimeActivity, WorkspaceRuntime}, + workspace_watch::monitor_workspace_snapshots, +}; use crate::{ OpencodeClientSnapshot, WorkspaceManager, WorkspaceManagerError, WorkspaceSnapshot, manager::Workspace, opencode, @@ -191,8 +196,8 @@ async fn watch_workspace_snapshot( abort_event_forward_task(&mut event_forward_task); event_generation.fetch_add(1, Ordering::Relaxed); - match read_unit_activity(&transient.unit).await { - UnitActivity::Stopped => { + match WorkspaceRuntime::read_activity(&transient.runtime).await { + RuntimeActivity::Stopped => { workspace.update(|next| { if next.transient.as_ref() == Some(&transient) { let mut changed = false; @@ -211,7 +216,7 @@ async fn watch_workspace_snapshot( }); last_client_uri = None; } - UnitActivity::Active | UnitActivity::Unknown => {} + RuntimeActivity::Active | RuntimeActivity::Unknown => {} } if !wait_for_change_or_timeout(&mut workspace_rx, HEALTH_RETRY_INTERVAL).await { @@ -256,10 +261,18 @@ fn create_opencode_client( current_uri: &str, shared_http_client: Option<&reqwest::Client>, ) -> opencode::client::Client { + let (baseurl, auth_header) = opencode_client_target(current_uri); + if let Some(auth_header) = auth_header { + return opencode::client::Client::new_with_client( + &baseurl, + build_authenticated_http_client(auth_header), + ); + } + if let Some(shared_http_client) = shared_http_client { - opencode::client::Client::new_with_client(current_uri, shared_http_client.clone()) + opencode::client::Client::new_with_client(&baseurl, shared_http_client.clone()) } else { - opencode::client::Client::new(current_uri) + opencode::client::Client::new(&baseurl) } } @@ -272,6 +285,46 @@ fn build_shared_http_client() -> Option { .ok() } +fn build_authenticated_http_client(auth_header: String) -> reqwest::Client { + let timeout = Duration::from_secs(15); + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::AUTHORIZATION, + reqwest::header::HeaderValue::from_str(&auth_header) + .expect("generated basic auth header should be valid"), + ); + reqwest::Client::builder() + .connect_timeout(timeout) + .timeout(timeout) + .default_headers(headers) + .build() + .expect("authenticated opencode http client should build") +} + +fn opencode_client_target(current_uri: &str) -> (String, Option) { + let Ok(mut url) = Url::parse(current_uri) else { + return (current_uri.to_string(), None); + }; + + let username = url.username().to_string(); + let password = url.password().map(str::to_string); + if username.is_empty() { + return (current_uri.to_string(), None); + } + + let _ = url.set_username(""); + let _ = url.set_password(None); + let credentials = match password { + Some(password) => format!("{username}:{password}"), + None => format!("{username}:"), + }; + let encoded = BASE64_STANDARD.encode(credentials); + ( + url.to_string().trim_end_matches('/').to_string(), + Some(format!("Basic {encoded}")), + ) +} + async fn forward_global_events( client: Arc, event_tx: broadcast::Sender, @@ -396,14 +449,8 @@ async fn wait_for_change_or_timeout( } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum UnitActivity { - Active, - Stopped, - Unknown, -} - -async fn read_unit_activity(unit: &str) -> UnitActivity { +#[cfg_attr(not(test), allow(dead_code))] +async fn read_unit_activity(unit: &str) -> RuntimeActivity { let output = match Command::new("systemctl") .args([ "--user", @@ -420,21 +467,21 @@ async fn read_unit_activity(unit: &str) -> UnitActivity { Ok(output) => output, Err(err) => { if err.kind() == std::io::ErrorKind::NotFound { - return UnitActivity::Unknown; + return RuntimeActivity::Unknown; } - return UnitActivity::Unknown; + return RuntimeActivity::Unknown; } }; if !output.status.success() { - return UnitActivity::Stopped; + return RuntimeActivity::Stopped; } let state = String::from_utf8_lossy(&output.stdout).trim().to_string(); if matches!(state.as_str(), "active" | "activating") { - UnitActivity::Active + RuntimeActivity::Active } else { - UnitActivity::Stopped + RuntimeActivity::Stopped } } @@ -450,7 +497,7 @@ mod tests { time::{SystemTime, UNIX_EPOCH}, }; - use crate::TransientWorkspaceSnapshot; + use crate::{RuntimeBackend, RuntimeHandleSnapshot, TransientWorkspaceSnapshot}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; struct TestDir { @@ -512,6 +559,31 @@ mod tests { } } + #[test] + fn opencode_client_target_extracts_basic_auth_header_and_strips_userinfo() { + let (baseurl, auth_header) = + opencode_client_target("http://opencode:secret@127.0.0.1:1234/"); + assert_eq!(baseurl, "http://127.0.0.1:1234"); + assert_eq!( + auth_header, + Some(format!( + "Basic {}", + BASE64_STANDARD.encode("opencode:secret") + )) + ); + } + + fn transient_snapshot(uri: String, runtime_id: &str) -> TransientWorkspaceSnapshot { + TransientWorkspaceSnapshot { + uri, + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: runtime_id.to_string(), + metadata: Default::default(), + }, + } + } + #[test] fn health_probe_client_reuses_cached_client_for_same_uri() { let mut cached_probe_client = None; @@ -591,10 +663,10 @@ mod tests { }); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("http://{addr}"), - unit: "run-u-health.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + format!("http://{addr}"), + "run-u-health.service", + )); true }); @@ -692,10 +764,10 @@ mod tests { }); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("http://{addr}/"), - unit: "run-u-health-trailing-slash.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + format!("http://{addr}/"), + "run-u-health-trailing-slash.service", + )); true }); @@ -786,10 +858,10 @@ mod tests { }); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("http://{addr}"), - unit: "run-u-events.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + format!("http://{addr}"), + "run-u-events.service", + )); true }); @@ -876,10 +948,10 @@ mod tests { let mut workspace_rx = workspace.subscribe(); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: "http://127.0.0.1:9".to_string(), - unit: "run-u-stopped.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + "http://127.0.0.1:9".to_string(), + "run-u-stopped.service", + )); true }); @@ -945,10 +1017,10 @@ mod tests { .expect("workspace should exist"); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: "http://127.0.0.1:9".to_string(), - unit: "run-u-active.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + "http://127.0.0.1:9".to_string(), + "run-u-active.service", + )); true }); @@ -980,7 +1052,7 @@ mod tests { let _path_guard = EnvVarGuard::set("PATH", empty_bin.as_os_str()); let activity = read_unit_activity("missing.service").await; - assert_eq!(activity, UnitActivity::Unknown); + assert_eq!(activity, RuntimeActivity::Unknown); }); } } diff --git a/lib/src/services/resource_usage_service.rs b/lib/src/services/resource_usage_service.rs index 36b4a57..4cfae37 100644 --- a/lib/src/services/resource_usage_service.rs +++ b/lib/src/services/resource_usage_service.rs @@ -6,7 +6,10 @@ use std::{ use tokio::{process::Command, sync::watch}; -use super::workspace_watch::monitor_workspace_snapshots; +use super::{ + runtime::{RuntimeUsageSample, RuntimeUsageState, WorkspaceRuntime}, + workspace_watch::monitor_workspace_snapshots, +}; use crate::{WorkspaceManager, WorkspaceManagerError, WorkspaceSnapshot, manager::Workspace}; const RESOURCE_MONITOR_INTERVAL: Duration = Duration::from_secs(2); @@ -22,19 +25,6 @@ impl From for ResourceUsageServiceError { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct UnitUsageSample { - memory_current: Option, - cpu_usage_nsec: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum UnitUsageState { - Active(UnitUsageSample), - Stopped, - Unknown, -} - pub async fn resource_usage_service( manager: Arc, ) -> Result<(), ResourceUsageServiceError> { @@ -70,33 +60,34 @@ async fn watch_workspace_snapshot( continue; }; - let unit_changed = sampled_unit.as_deref() != Some(transient.unit.as_str()); + let unit_changed = sampled_unit.as_deref() != Some(transient.runtime.id.as_str()); if unit_changed { previous_cpu_sample = None; - sampled_unit = Some(transient.unit.clone()); + sampled_unit = Some(transient.runtime.id.clone()); next_sample_at = None; } let now = Instant::now(); if should_sample_usage(now, next_sample_at) { - match read_unit_usage(&transient.unit).await { - UnitUsageState::Active(usage_sample) => { - let (cpu_percent, next_cpu_sample) = cpu_percent_from_sample( - previous_cpu_sample, - usage_sample.cpu_usage_nsec, - now, - ); + match WorkspaceRuntime::read_usage(&transient.runtime).await { + RuntimeUsageSample { + state: Some(RuntimeUsageState::Active), + memory_current, + cpu_usage_nsec, + } => { + let (cpu_percent, next_cpu_sample) = + cpu_percent_from_sample(previous_cpu_sample, cpu_usage_nsec, now); previous_cpu_sample = next_cpu_sample; refresh_resource_usage( &workspace, - &transient.unit, + &transient.runtime.id, cpu_percent, - usage_sample.memory_current, + memory_current, ); } - UnitUsageState::Stopped | UnitUsageState::Unknown => { + RuntimeUsageSample { .. } => { previous_cpu_sample = None; - clear_resource_usage_for_unit(&workspace, &transient.unit); + clear_resource_usage_for_unit(&workspace, &transient.runtime.id); } } next_sample_at = Some(now + RESOURCE_MONITOR_INTERVAL); @@ -133,7 +124,7 @@ fn refresh_resource_usage( let still_tracking_same_unit = snapshot .transient .as_ref() - .map(|transient| transient.unit.as_str() == unit) + .map(|transient| transient.runtime.id.as_str() == unit) .unwrap_or(false); let should_update = still_tracking_same_unit && (snapshot.usage_cpu_percent != cpu_percent || snapshot.usage_ram_bytes != ram_bytes); @@ -166,7 +157,7 @@ fn clear_resource_usage_for_unit(workspace: &Workspace, unit: &str) { let still_tracking_same_unit = snapshot .transient .as_ref() - .map(|transient| transient.unit.as_str() == unit) + .map(|transient| transient.runtime.id.as_str() == unit) .unwrap_or(false); if has_usage && still_tracking_same_unit { snapshot.usage_cpu_percent = None; @@ -201,7 +192,8 @@ fn cpu_percent_from_sample( (cpu_percent, Some((current_cpu_usage_nsec, now))) } -async fn read_unit_usage(unit: &str) -> UnitUsageState { +#[cfg_attr(not(test), allow(dead_code))] +async fn read_unit_usage(unit: &str) -> RuntimeUsageSample { let output = match Command::new("systemctl") .args([ "--user", @@ -221,20 +213,30 @@ async fn read_unit_usage(unit: &str) -> UnitUsageState { Ok(output) => output, Err(err) => { if err.kind() == std::io::ErrorKind::NotFound { - return UnitUsageState::Unknown; + return RuntimeUsageSample { + state: Some(RuntimeUsageState::Unknown), + ..Default::default() + }; } - return UnitUsageState::Unknown; + return RuntimeUsageSample { + state: Some(RuntimeUsageState::Unknown), + ..Default::default() + }; } }; if !output.status.success() { - return UnitUsageState::Stopped; + return RuntimeUsageSample { + state: Some(RuntimeUsageState::Stopped), + ..Default::default() + }; } parse_unit_usage_show_output(&String::from_utf8_lossy(&output.stdout)) } -fn parse_unit_usage_show_output(output: &str) -> UnitUsageState { +#[cfg_attr(not(test), allow(dead_code))] +fn parse_unit_usage_show_output(output: &str) -> RuntimeUsageSample { let mut active_state: Option<&str> = None; let mut memory_current: Option = None; let mut cpu_usage_nsec: Option = None; @@ -253,16 +255,18 @@ fn parse_unit_usage_show_output(output: &str) -> UnitUsageState { } let active_state = active_state.unwrap_or_default(); - if !matches!(active_state, "active" | "activating") { - return UnitUsageState::Stopped; - } - - UnitUsageState::Active(UnitUsageSample { + RuntimeUsageSample { memory_current, cpu_usage_nsec, - }) + state: Some(if matches!(active_state, "active" | "activating") { + RuntimeUsageState::Active + } else { + RuntimeUsageState::Stopped + }), + } } +#[cfg_attr(not(test), allow(dead_code))] fn parse_systemctl_u64(value: &str) -> Option { let trimmed = value.trim(); if trimmed.is_empty() || trimmed == "[not set]" { @@ -290,10 +294,11 @@ mod tests { let output = "ActiveState=active\nMemoryCurrent=4096\nCPUUsageNSec=2000000000\n"; assert_eq!( parse_unit_usage_show_output(output), - UnitUsageState::Active(UnitUsageSample { + RuntimeUsageSample { memory_current: Some(4096), cpu_usage_nsec: Some(2_000_000_000), - }) + state: Some(RuntimeUsageState::Active), + } ); } @@ -302,10 +307,11 @@ mod tests { let output = "ActiveState=active\nMemoryCurrent=[not set]\n"; assert_eq!( parse_unit_usage_show_output(output), - UnitUsageState::Active(UnitUsageSample { + RuntimeUsageSample { memory_current: None, cpu_usage_nsec: None, - }) + state: Some(RuntimeUsageState::Active), + } ); } @@ -313,7 +319,11 @@ mod tests { fn parse_unit_usage_show_output_reports_stopped_for_inactive_state() { assert_eq!( parse_unit_usage_show_output("ActiveState=inactive\nMemoryCurrent=0\nCPUUsageNSec=0\n"), - UnitUsageState::Stopped + RuntimeUsageSample { + memory_current: Some(0), + cpu_usage_nsec: Some(0), + state: Some(RuntimeUsageState::Stopped), + } ); } @@ -322,10 +332,11 @@ mod tests { let output = "CPUUsageNSec=300\nActiveState=active\nMemoryCurrent=1024\n"; assert_eq!( parse_unit_usage_show_output(output), - UnitUsageState::Active(UnitUsageSample { + RuntimeUsageSample { memory_current: Some(1024), cpu_usage_nsec: Some(300), - }) + state: Some(RuntimeUsageState::Active), + } ); } diff --git a/lib/src/services/root_session_service.rs b/lib/src/services/root_session_service.rs index 8a511a0..a567e1d 100644 --- a/lib/src/services/root_session_service.rs +++ b/lib/src/services/root_session_service.rs @@ -416,7 +416,9 @@ fn normalize_base_uri(uri: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::{OpencodeClientSnapshot, TransientWorkspaceSnapshot}; + use crate::{ + OpencodeClientSnapshot, RuntimeBackend, RuntimeHandleSnapshot, TransientWorkspaceSnapshot, + }; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, sync::Notify, @@ -440,6 +442,17 @@ mod tests { .to_string() } + fn transient_snapshot(uri: &str, runtime_id: &str) -> TransientWorkspaceSnapshot { + TransientWorkspaceSnapshot { + uri: uri.to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: runtime_id.to_string(), + metadata: Default::default(), + }, + } + } + fn sessions_json_with_subagent( root_session_id: &str, root_title: &str, @@ -651,10 +664,10 @@ mod tests { }; workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-session.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + &format!("{base_uri}/"), + "run-u-root-session.service", + )); snapshot.opencode_client = Some(client_snapshot.clone()); true }); @@ -781,10 +794,10 @@ mod tests { }; workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-session.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + &format!("{base_uri}/"), + "run-u-root-session.service", + )); snapshot.opencode_client = Some(client_snapshot.clone()); true }); @@ -894,10 +907,10 @@ mod tests { }; workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-session.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + &format!("{base_uri}/"), + "run-u-root-session.service", + )); snapshot.opencode_client = Some(client_snapshot.clone()); true }); @@ -1053,10 +1066,10 @@ mod tests { }; workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-session.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + &format!("{base_uri}/"), + "run-u-root-session.service", + )); snapshot.opencode_client = Some(client_snapshot.clone()); true }); @@ -1194,10 +1207,10 @@ mod tests { }; workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-session.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + &format!("{base_uri}/"), + "run-u-root-session.service", + )); snapshot.opencode_client = Some(client_snapshot.clone()); true }); @@ -1310,10 +1323,8 @@ mod tests { let base_uri = format!("http://{addr}"); let client = Arc::new(opencode::client::Client::new(&base_uri)); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-race.service".to_string(), - }); + snapshot.transient = + Some(transient_snapshot(&format!("{base_uri}/"), "run-u-root-race.service")); snapshot.opencode_client = Some(OpencodeClientSnapshot { client: client.clone(), events: event_tx.clone(), @@ -1437,10 +1448,8 @@ mod tests { let base_uri = format!("http://{addr}"); let old_client = Arc::new(opencode::client::Client::new(&base_uri)); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-stale.service".to_string(), - }); + snapshot.transient = + Some(transient_snapshot(&format!("{base_uri}/"), "run-u-root-stale.service")); snapshot.opencode_client = Some(OpencodeClientSnapshot { client: old_client.clone(), events: old_event_tx.clone(), @@ -1558,10 +1567,10 @@ mod tests { let old_client = Arc::new(opencode::client::Client::new(&base_uri)); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-same-uri.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + &format!("{base_uri}/"), + "run-u-root-same-uri.service", + )); snapshot.opencode_client = Some(OpencodeClientSnapshot { client: old_client, events: old_event_tx, @@ -1688,10 +1697,10 @@ mod tests { let base_uri = format!("http://{addr}"); let client = Arc::new(opencode::client::Client::new(&base_uri)); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-root-ignore-non-session.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + &format!("{base_uri}/"), + "run-u-root-ignore-non-session.service", + )); snapshot.opencode_client = Some(OpencodeClientSnapshot { client: client.clone(), events: event_tx.clone(), @@ -1750,10 +1759,10 @@ mod tests { let (old_event_tx, _) = broadcast::channel(64); let old_client = Arc::new(opencode::client::Client::new("http://127.0.0.1:9")); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: "http://127.0.0.1:9/".to_string(), - unit: "run-u-root-old-uri.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + "http://127.0.0.1:9/", + "run-u-root-old-uri.service", + )); snapshot.opencode_client = Some(OpencodeClientSnapshot { client: old_client, events: old_event_tx, @@ -1774,10 +1783,10 @@ mod tests { let (new_event_tx, _) = broadcast::channel(64); let new_client = Arc::new(opencode::client::Client::new("http://127.0.0.1:10")); workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: "http://127.0.0.1:10/".to_string(), - unit: "run-u-root-new-uri.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + "http://127.0.0.1:10/", + "run-u-root-new-uri.service", + )); snapshot.opencode_client = Some(OpencodeClientSnapshot { client: new_client, events: new_event_tx, diff --git a/lib/src/services/transient_storage.rs b/lib/src/services/transient_storage.rs index 8119795..ca26319 100644 --- a/lib/src/services/transient_storage.rs +++ b/lib/src/services/transient_storage.rs @@ -6,7 +6,7 @@ use std::{ use tokio::sync::watch; use uuid::Uuid; -use super::workspace_watch::monitor_workspace_snapshots; +use super::{config::synthesized_xdg_runtime_dir, workspace_watch::monitor_workspace_snapshots}; use crate::{ TransientWorkspaceSnapshot, WorkspaceManager, WorkspaceManagerError, WorkspaceSnapshot, }; @@ -55,7 +55,7 @@ pub async fn transient_storage( let snapshot_path = snapshot_file_path(storage_dir.as_ref(), &key); let transient_snapshot = read_transient_snapshot(&snapshot_path).await?; workspace.update(|snapshot| { - if snapshot.transient != transient_snapshot { + if snapshot.transient.is_none() && transient_snapshot.is_some() { snapshot.transient = transient_snapshot.clone(); true } else { @@ -65,6 +65,12 @@ pub async fn transient_storage( let current_transient = workspace_rx.borrow_and_update().transient.clone(); tokio::spawn(async move { + if let Err(err) = + persist_transient_snapshot(&snapshot_path, current_transient.as_ref()).await + { + tracing::error!(error = ?err, "failed to persist initial transient snapshot"); + return; + } if let Err(err) = watch_workspace_snapshot(snapshot_path, workspace_rx, current_transient).await { @@ -113,8 +119,9 @@ async fn ensure_storage_directory( } Err(err) if err.kind() == std::io::ErrorKind::NotFound => { let runtime_dir = std::env::var_os("XDG_RUNTIME_DIR") + .map(PathBuf::from) + .or_else(synthesized_xdg_runtime_dir) .ok_or(TransientStorageError::MissingXdgRuntimeDir)?; - let runtime_dir = PathBuf::from(runtime_dir); if !runtime_dir.is_absolute() { return Err(TransientStorageError::InvalidXdgRuntimeDir(runtime_dir)); } @@ -271,14 +278,15 @@ async fn persist_transient_snapshot( #[cfg(test)] mod tests { use super::*; - use crate::WorkspaceManager; use crate::test_support::ENV_VAR_LOCK; + use crate::{RuntimeBackend, RuntimeHandleSnapshot, WorkspaceManager}; use std::{ ffi::OsString, fs, path::{Path, PathBuf}, - time::{Duration, SystemTime, UNIX_EPOCH}, + time::Duration, }; + use uuid::Uuid; struct TestDir { path: PathBuf, @@ -286,14 +294,10 @@ mod tests { impl TestDir { fn new() -> Self { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time should be after unix epoch") - .as_nanos(); let path = std::env::temp_dir().join(format!( "multicode-transient-storage-{}-{}", std::process::id(), - unique + Uuid::new_v4().as_simple() )); fs::create_dir_all(&path).expect("test dir should be created"); Self { path } @@ -323,6 +327,14 @@ mod tests { } Self { key, old_value } } + + fn remove(key: &'static str) -> Self { + let old_value = std::env::var_os(key); + unsafe { + std::env::remove_var(key); + } + Self { key, old_value } + } } impl Drop for EnvVarGuard { @@ -397,6 +409,36 @@ mod tests { }); } + #[cfg(target_os = "macos")] + #[test] + fn ensure_storage_directory_synthesizes_xdg_runtime_dir_on_macos() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_VAR_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let root = TestDir::new(); + let _guard = EnvVarGuard::remove("XDG_RUNTIME_DIR"); + + let link = root.path().join("state/transient-link"); + let target = ensure_storage_directory(&link) + .await + .expect("storage directory should be created"); + + assert!( + target.starts_with( + synthesized_xdg_runtime_dir() + .expect("macOS should synthesize XDG runtime dir") + .join("multicode") + ) + ); + }); + } + #[test] fn ensure_storage_directory_creates_missing_symlink_target() { let runtime = tokio::runtime::Builder::new_current_thread() @@ -450,7 +492,11 @@ mod tests { let initial_transient = TransientWorkspaceSnapshot { uri: "file:///initial".to_string(), - unit: "run-u-initial.service".to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: "run-u-initial.service".to_string(), + metadata: Default::default(), + }, }; let snapshot_path = storage_dir.join("alpha.json"); tokio::fs::write( @@ -479,7 +525,11 @@ mod tests { .update(|snapshot| { snapshot.transient = Some(TransientWorkspaceSnapshot { uri: "file:///updated".to_string(), - unit: "run-u-updated.service".to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: "run-u-updated.service".to_string(), + metadata: Default::default(), + }, }); true }); @@ -491,7 +541,7 @@ mod tests { .expect("snapshot file should stay readable"); let snapshot: TransientWorkspaceSnapshot = serde_json::from_slice(&content).expect("snapshot should parse"); - if snapshot.unit == "run-u-updated.service" { + if snapshot.runtime.id == "run-u-updated.service" { break; } tokio::time::sleep(Duration::from_millis(10)).await; @@ -576,4 +626,74 @@ mod tests { service_task.abort(); }); } + + #[test] + fn transient_storage_does_not_clobber_live_transient_state_when_disk_snapshot_is_missing() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let storage_dir = root.path().join("storage"); + tokio::fs::create_dir_all(&storage_dir) + .await + .expect("storage dir should exist"); + + let link = root.path().join("transient-link"); + tokio::fs::symlink(&storage_dir, &link) + .await + .expect("symlink should be created"); + + let manager = Arc::new(WorkspaceManager::new()); + manager + .add("alpha") + .expect("workspace should be added before service starts"); + + let live_transient = TransientWorkspaceSnapshot { + uri: "http://opencode:secret@127.0.0.1:31337/".to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::AppleContainer, + id: "multicode-alpha".to_string(), + metadata: Default::default(), + }, + }; + manager + .get_workspace("alpha") + .expect("workspace should exist") + .update(|snapshot| { + snapshot.transient = Some(live_transient.clone()); + true + }); + + let alpha_rx = manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe(); + let service_task = tokio::spawn(transient_storage(manager.clone(), link.clone())); + + tokio::time::sleep(Duration::from_millis(100)).await; + assert_eq!(alpha_rx.borrow().transient, Some(live_transient.clone())); + + let snapshot_path = storage_dir.join("alpha.json"); + tokio::time::timeout(Duration::from_secs(2), async { + loop { + let content = tokio::fs::read(&snapshot_path) + .await + .expect("snapshot file should be written"); + let snapshot: TransientWorkspaceSnapshot = + serde_json::from_slice(&content).expect("snapshot should parse"); + if snapshot == live_transient { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("live transient state should be persisted"); + + service_task.abort(); + }); + } } diff --git a/lib/src/services/usage_aggregation_service.rs b/lib/src/services/usage_aggregation_service.rs index a7a5c13..d8943c8 100644 --- a/lib/src/services/usage_aggregation_service.rs +++ b/lib/src/services/usage_aggregation_service.rs @@ -354,9 +354,22 @@ fn sum_usage(usage_by_message: &HashMap) -> (u64, f64) { #[cfg(test)] mod tests { use super::*; - use crate::{OpencodeClientSnapshot, TransientWorkspaceSnapshot}; + use crate::{ + OpencodeClientSnapshot, RuntimeBackend, RuntimeHandleSnapshot, TransientWorkspaceSnapshot, + }; use tokio::io::{AsyncReadExt, AsyncWriteExt}; + fn transient_snapshot(uri: String, runtime_id: &str) -> TransientWorkspaceSnapshot { + TransientWorkspaceSnapshot { + uri, + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: runtime_id.to_string(), + metadata: Default::default(), + }, + } + } + fn assistant_message_json( message_id: &str, session_id: &str, @@ -507,10 +520,10 @@ mod tests { events: event_tx, }; workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-usage.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + format!("{base_uri}/"), + "run-u-usage.service", + )); snapshot.root_session_id = Some("ses-root".to_string()); snapshot.opencode_client = Some(client_snapshot.clone()); true @@ -653,10 +666,10 @@ mod tests { events: event_tx.clone(), }; workspace.update(|snapshot| { - snapshot.transient = Some(TransientWorkspaceSnapshot { - uri: format!("{base_uri}/"), - unit: "run-u-usage-events.service".to_string(), - }); + snapshot.transient = Some(transient_snapshot( + format!("{base_uri}/"), + "run-u-usage-events.service", + )); snapshot.root_session_id = Some("ses-root".to_string()); snapshot.opencode_client = Some(client_snapshot.clone()); true diff --git a/remote/src/orchestration.rs b/remote/src/orchestration.rs index ecb675d..b04d505 100644 --- a/remote/src/orchestration.rs +++ b/remote/src/orchestration.rs @@ -529,7 +529,9 @@ fn should_sync_bidirectional_mapping_up( remote_latest: Option, ) -> bool { match (local_latest, remote_latest) { - (Some(_), Some(_)) => compare_sync_tree_recency(local_latest, remote_latest) != Ordering::Less, + (Some(_), Some(_)) => { + compare_sync_tree_recency(local_latest, remote_latest) != Ordering::Less + } (Some(_), None) => true, (None, Some(_)) => false, (None, None) => true, @@ -2179,7 +2181,9 @@ mod tests { std::fs::create_dir_all(&local_dir).expect("local dir should be created"); let mapping = ResolvedSyncPathMapping { local: local_dir.clone(), - remote: PathBuf::from("/home/alice/dev/agent-work/.multicode/remote/added-skills/workspace-skills/skill-alpha"), + remote: PathBuf::from( + "/home/alice/dev/agent-work/.multicode/remote/added-skills/workspace-skills/skill-alpha", + ), exclude: Vec::new(), dereference_symlinks: false, local_is_dir: true, @@ -2194,7 +2198,10 @@ mod tests { ) .expect("directory sync args should build"); - assert!(args.iter().any(|arg| arg == &format!("{}/", local_dir.to_string_lossy()))); + assert!( + args.iter() + .any(|arg| arg == &format!("{}/", local_dir.to_string_lossy())) + ); assert!(!args.iter().any(|arg| arg == "--mkpath")); assert!(args.iter().any(|arg| { arg == "alice@example.com:/home/alice/dev/agent-work/.multicode/remote/added-skills/workspace-skills/skill-alpha/" diff --git a/remote/tests/docker_remote_integration.rs b/remote/tests/docker_remote_integration.rs index 68090b3..17c76ab 100644 --- a/remote/tests/docker_remote_integration.rs +++ b/remote/tests/docker_remote_integration.rs @@ -135,9 +135,15 @@ fn build_probe_binary() -> PathBuf { .current_dir(&repo_root) .status() .expect("cargo build for multicode-tui should run"); - assert!(build_status.success(), "multicode-tui should build for integration test"); + assert!( + build_status.success(), + "multicode-tui should build for integration test" + ); let probe_binary = repo_root.join("target/debug/multicode-tui"); - assert!(probe_binary.exists(), "built multicode-tui binary should exist"); + assert!( + probe_binary.exists(), + "built multicode-tui binary should exist" + ); probe_binary } @@ -201,7 +207,11 @@ CMD ["/usr/sbin/sshd", "-D", "-e"] ) .expect("dockerfile should be written"); - let image = format!("multicode-remote-test-bidi-matrix-{}:{}", case.test_name(), std::process::id()); + let image = format!( + "multicode-remote-test-bidi-matrix-{}:{}", + case.test_name(), + std::process::id() + ); let build = StdCommand::new("docker") .args(["build", "-t", &image, "."]) .current_dir(root.path()) @@ -210,7 +220,11 @@ CMD ["/usr/sbin/sshd", "-D", "-e"] assert!(build.success(), "docker build should succeed"); let port = reserve_tcp_port(); - let container_name = format!("multicode-remote-test-bidi-matrix-{}-{}", case.test_name(), std::process::id()); + let container_name = format!( + "multicode-remote-test-bidi-matrix-{}-{}", + case.test_name(), + std::process::id() + ); let run = StdCommand::new("docker") .args([ "run", @@ -229,7 +243,9 @@ CMD ["/usr/sbin/sshd", "-D", "-e"] .status() .expect("docker run should execute"); assert!(run.success(), "docker run should succeed"); - let _container = DockerContainerGuard { name: container_name.clone() }; + let _container = DockerContainerGuard { + name: container_name.clone(), + }; wait_for_ssh(port, &key_path, &known_hosts).await; @@ -320,8 +336,13 @@ CMD ["/usr/sbin/sshd", "-D", "-e"] .output() .await .expect("remote seed probe should run"); - assert!(remote_seed.status.success(), "remote seed probe should succeed"); - let remote_seed_text = String::from_utf8_lossy(&remote_seed.stdout).trim().to_string(); + assert!( + remote_seed.status.success(), + "remote seed probe should succeed" + ); + let remote_seed_text = String::from_utf8_lossy(&remote_seed.stdout) + .trim() + .to_string(); let remote_parent_probe = Command::new("ssh") .args([ @@ -339,31 +360,67 @@ CMD ["/usr/sbin/sshd", "-D", "-e"] .status() .await .expect("remote parent probe should run"); - assert!(remote_parent_probe.success(), "bidi sync must not place files in the remote parent directory"); + assert!( + remote_parent_probe.success(), + "bidi sync must not place files in the remote parent directory" + ); let local_seed_path = bidi_local.join("seed.txt"); - let local_seed_text = fs::read_to_string(&local_seed_path).ok().map(|text| text.trim().to_string()); + let local_seed_text = fs::read_to_string(&local_seed_path) + .ok() + .map(|text| text.trim().to_string()); assert!( - !bidi_local.parent().expect("bidi local parent should exist").join("seed.txt").exists(), + !bidi_local + .parent() + .expect("bidi local parent should exist") + .join("seed.txt") + .exists(), "bidi sync must not place files in the local parent directory" ); match case { BidiExistenceCase::LocalAndRemoteMissing => { - assert_eq!(remote_seed_text, "", "remote destination should remain empty when both sides start empty"); - assert!(!local_seed_path.exists(), "local destination should remain empty when both sides start empty"); + assert_eq!( + remote_seed_text, "", + "remote destination should remain empty when both sides start empty" + ); + assert!( + !local_seed_path.exists(), + "local destination should remain empty when both sides start empty" + ); } BidiExistenceCase::LocalOnly => { - assert_eq!(remote_seed_text, "local-seed", "initial upload should seed the exact remote destination from the local directory"); - assert_eq!(local_seed_text.as_deref(), Some("local-seed"), "local seed should remain in the configured local directory"); + assert_eq!( + remote_seed_text, "local-seed", + "initial upload should seed the exact remote destination from the local directory" + ); + assert_eq!( + local_seed_text.as_deref(), + Some("local-seed"), + "local seed should remain in the configured local directory" + ); } BidiExistenceCase::RemoteOnly => { - assert_eq!(remote_seed_text, "remote-seed", "remote-only case should preserve the exact remote destination contents"); - assert_eq!(local_seed_text.as_deref(), Some("remote-seed"), "final sync-down should place remote contents into the configured local directory"); + assert_eq!( + remote_seed_text, "remote-seed", + "remote-only case should preserve the exact remote destination contents" + ); + assert_eq!( + local_seed_text.as_deref(), + Some("remote-seed"), + "final sync-down should place remote contents into the configured local directory" + ); } BidiExistenceCase::LocalAndRemotePresent => { - assert_eq!(remote_seed_text, "remote-seed", "newer remote content should win within the configured remote destination"); - assert_eq!(local_seed_text.as_deref(), Some("remote-seed"), "newer remote content should sync down into the configured local directory"); + assert_eq!( + remote_seed_text, "remote-seed", + "newer remote content should win within the configured remote destination" + ); + assert_eq!( + local_seed_text.as_deref(), + Some("remote-seed"), + "newer remote content should sync down into the configured local directory" + ); } } } @@ -675,7 +732,6 @@ CMD ["/usr/sbin/sshd", "-D", "-e"] }); } - #[test] fn docker_remote_flow_bidi_sync_handles_both_missing() { let runtime = tokio::runtime::Builder::new_current_thread() diff --git a/tui/src/app.rs b/tui/src/app.rs index a8621b4..8c17a5a 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -788,9 +788,10 @@ impl TuiState { } }; - let mut tmux_command = vec!["systemd-run".to_string()]; let inherited_env = exec_command.inherited_env; - tmux_command.extend(exec_command.args); + let tmux_command = std::iter::once(exec_command.program) + .chain(exec_command.args) + .collect::>(); let custom_description = self .snapshots .get(workspace_key) @@ -861,9 +862,10 @@ impl TuiState { io::Error::other(format!("failed to prepare PTY review handler: {err:?}")) })?; - let mut tmux_command = vec!["systemd-run".to_string()]; let inherited_env = command.inherited_env; - tmux_command.extend(command.args); + let tmux_command = std::iter::once(command.program) + .chain(command.args) + .collect::>(); let custom_description = self .snapshots .get(workspace_key) diff --git a/tui/src/ops.rs b/tui/src/ops.rs index 024894d..5206b8a 100644 --- a/tui/src/ops.rs +++ b/tui/src/ops.rs @@ -1,4 +1,6 @@ use crate::*; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; pub(crate) fn shell_escape_arg(arg: &str) -> String { if arg.is_empty() { @@ -208,6 +210,18 @@ pub(crate) async fn run_tmux_new_session_command( workspace_key: &str, custom_description: &str, ) -> io::Result<()> { + if !command_exists("tmux") { + let debug_command = command + .split_first() + .map(|(program, args)| format_command_line(program, args)) + .unwrap_or_else(|| "".to_string()); + tracing::info!( + command = %debug_command, + "tmux unavailable; running interactive command directly" + ); + return run_interactive_command(terminal, env, &command).await; + } + restore_terminal(terminal)?; let session_name = generate_tmux_session_name(workspace_key); @@ -322,6 +336,58 @@ pub(crate) async fn run_tmux_new_session_command( } } +pub(crate) fn command_exists(command: &str) -> bool { + if command.contains('/') { + return is_executable_file(Path::new(command)); + } + + let Some(path) = std::env::var_os("PATH") else { + return false; + }; + std::env::split_paths(&path) + .map(|directory| directory.join(command)) + .any(|candidate| is_executable_file(&candidate)) +} + +fn is_executable_file(path: &Path) -> bool { + let Ok(metadata) = std::fs::metadata(path) else { + return false; + }; + metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 +} + +async fn run_interactive_command( + terminal: &mut Terminal>, + env: &[(String, String)], + command: &[String], +) -> io::Result<()> { + let Some((program, args)) = command.split_first() else { + return Err(io::Error::other("interactive command must not be empty")); + }; + + restore_terminal(terminal)?; + let status = Command::new(program) + .args(args) + .envs(env.iter().cloned()) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + .await; + let setup_result = setup_terminal().map(|new_terminal| { + *terminal = new_terminal; + }); + + match (status, setup_result) { + (_, Err(err)) => Err(err), + (Ok(status), Ok(())) if status.success() => Ok(()), + (Ok(status), Ok(())) => Err(io::Error::other(format!( + "interactive command exited with status {status}" + ))), + (Err(err), Ok(())) => Err(err), + } +} + pub(crate) async fn set_tmux_session_option( session_name: &str, option: &str, diff --git a/tui/src/tests.rs b/tui/src/tests.rs index 866ebc3..09c5957 100644 --- a/tui/src/tests.rs +++ b/tui/src/tests.rs @@ -11,9 +11,9 @@ mod tests { pr_review_icon_color, }; use crate::ops::{ - SessionWaitState, attach_cli_args, build_handler_command, session_wait_state_for_entry, - tmux_session_command, tmux_status_left, validate_workspace_link_target, - workspace_attach_target, workspace_ordering, + SessionWaitState, attach_cli_args, build_handler_command, command_exists, + session_wait_state_for_entry, tmux_session_command, tmux_status_left, + validate_workspace_link_target, workspace_attach_target, workspace_ordering, }; use crate::render::selected_link_tooltip_area; use crate::system::{ @@ -21,9 +21,13 @@ mod tests { parse_proc_meminfo_total_ram_bytes, parse_proc_meminfo_used_ram_bytes, started_workspace_attach_ready, }; - use multicode_lib::{PersistentWorkspaceSnapshot, TransientWorkspaceSnapshot}; + use multicode_lib::{ + PersistentWorkspaceSnapshot, RuntimeBackend, RuntimeHandleSnapshot, + TransientWorkspaceSnapshot, + }; use std::{ fs, + os::unix::fs::PermissionsExt, path::PathBuf, time::{SystemTime, UNIX_EPOCH}, }; @@ -64,7 +68,11 @@ mod tests { persistent: PersistentWorkspaceSnapshot::default(), transient: uri.map(|uri| TransientWorkspaceSnapshot { uri: uri.to_string(), - unit: "unit.service".to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: "unit.service".to_string(), + metadata: Default::default(), + }, }), opencode_client: started.then(|| multicode_lib::OpencodeClientSnapshot { client: std::sync::Arc::new(multicode_lib::opencode::client::Client::new( @@ -88,7 +96,11 @@ mod tests { persistent: PersistentWorkspaceSnapshot::default(), transient: Some(TransientWorkspaceSnapshot { uri: "http://127.0.0.1".to_string(), - unit: "unit.service".to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: "unit.service".to_string(), + metadata: Default::default(), + }, }), opencode_client: None, root_session_id: None, @@ -222,6 +234,38 @@ mod tests { ); } + #[test] + fn command_exists_detects_executable_files_on_path() { + let root = TestDir::new(); + let bin_dir = root.path().join("bin"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + let tool_path = bin_dir.join("multicode-test-tool"); + fs::write(&tool_path, "#!/bin/sh\nexit 0\n").expect("tool should be written"); + let mut perms = fs::metadata(&tool_path) + .expect("tool metadata should exist") + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(&tool_path, perms).expect("tool should be executable"); + + let old_path = std::env::var_os("PATH"); + unsafe { + std::env::set_var("PATH", bin_dir.as_os_str()); + } + + assert!(command_exists("multicode-test-tool")); + assert!(!command_exists("missing-tool")); + + if let Some(path) = old_path { + unsafe { + std::env::set_var("PATH", path); + } + } else { + unsafe { + std::env::remove_var("PATH"); + } + } + } + #[test] fn tui_cli_args_accept_optional_relay_socket() { let parsed = crate::CliArgs::try_parse_from([ From 0b2652408661ea5ab63eace5cd693c916caeb6df Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Thu, 9 Apr 2026 09:23:46 +0200 Subject: [PATCH 2/4] Initial support for using Apple containers for isolation on MacOS --- apple-container/Containerfile | 57 + apple-container/build-local.sh | 11 + lib/src/services/runtime.rs | 1880 +++++++++++++++++ .../runtime_reconciliation_service.rs | 159 ++ .../apple_container_runtime_integration.rs | 690 ++++++ 5 files changed, 2797 insertions(+) create mode 100644 apple-container/Containerfile create mode 100755 apple-container/build-local.sh create mode 100644 lib/src/services/runtime.rs create mode 100644 lib/src/services/runtime_reconciliation_service.rs create mode 100644 lib/tests/apple_container_runtime_integration.rs diff --git a/apple-container/Containerfile b/apple-container/Containerfile new file mode 100644 index 0000000..e048edc --- /dev/null +++ b/apple-container/Containerfile @@ -0,0 +1,57 @@ +FROM node:22-bookworm-slim AS node + +FROM ghcr.io/graalvm/native-image-community:25 + +ARG HOST_UID=1000 +ARG HOST_GID=1000 +ARG GH_VERSION=2.83.2 + +COPY --from=node /usr/local/ /usr/local/ + +RUN set -eux; \ + microdnf install -y \ + bash \ + ca-certificates \ + curl \ + git \ + openssh-clients \ + procps-ng \ + rsync \ + shadow-utils \ + tar \ + tmux \ + unzip \ + xz \ + zstd; \ + microdnf clean all; \ + arch="$(uname -m)"; \ + case "${arch}" in \ + aarch64|arm64) gh_arch="arm64" ;; \ + x86_64|amd64) gh_arch="amd64" ;; \ + *) echo "unsupported architecture: ${arch}" >&2; exit 1 ;; \ + esac; \ + curl -fsSL "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_${gh_arch}.tar.gz" \ + -o /tmp/gh.tar.gz; \ + tar -xzf /tmp/gh.tar.gz -C /tmp; \ + install "/tmp/gh_${GH_VERSION}_linux_${gh_arch}/bin/gh" /usr/local/bin/gh; \ + rm -rf /tmp/gh.tar.gz "/tmp/gh_${GH_VERSION}_linux_${gh_arch}"; \ + npm install -g opencode-ai; \ + if ! getent group "${HOST_GID}" >/dev/null; then \ + groupadd --gid "${HOST_GID}" multicode; \ + fi; \ + useradd \ + --uid "${HOST_UID}" \ + --gid "${HOST_GID}" \ + --create-home \ + --shell /bin/bash \ + multicode + +ENV HOME=/home/multicode +ENV USER=multicode +ENV PATH=/usr/local/bin:${PATH} + +USER multicode +WORKDIR /workspace +ENTRYPOINT [] + +CMD ["/bin/bash"] diff --git a/apple-container/build-local.sh b/apple-container/build-local.sh new file mode 100755 index 0000000..12d3da1 --- /dev/null +++ b/apple-container/build-local.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) + +exec container build \ + -t multicode-java25:latest \ + -f "$SCRIPT_DIR/Containerfile" \ + --build-arg "HOST_UID=$(id -u)" \ + --build-arg "HOST_GID=$(id -g)" \ + "$SCRIPT_DIR" diff --git a/lib/src/services/runtime.rs b/lib/src/services/runtime.rs new file mode 100644 index 0000000..cb1697a --- /dev/null +++ b/lib/src/services/runtime.rs @@ -0,0 +1,1880 @@ +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, + process::{Output, Stdio}, +}; + +use tokio::process::Command; +use uuid::Uuid; + +use super::{ + combined::{CombinedServiceError, SpawnCommand}, + config::{ExpandedIsolationConfig, RuntimeConfig, path_looks_like_file}, +}; +use crate::{RuntimeBackend, RuntimeHandleSnapshot, TransientWorkspaceSnapshot}; + +pub(super) const RUNTIME_SPEC_METADATA_KEY: &str = "runtime-spec"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RuntimeActivity { + Active, + Stopped, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RuntimeUsageState { + Active, + Stopped, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(super) struct RuntimeUsageSample { + pub(super) memory_current: Option, + pub(super) cpu_usage_nsec: Option, + pub(super) state: Option, +} + +#[derive(Debug, Clone)] +pub(super) struct RuntimeStartResult { + pub(super) transient: TransientWorkspaceSnapshot, +} + +#[derive(Debug, Clone)] +struct RuntimeContext { + runtime: RuntimeConfig, + workspace_directory_path: PathBuf, + expanded_isolation: ExpandedIsolationConfig, + host_opencode_command: String, + container_opencode_command: String, +} + +#[derive(Debug, Clone)] +pub(super) enum WorkspaceRuntime { + Linux(LinuxSystemdBwrapRuntime), + AppleContainer(AppleContainerRuntime), +} + +impl WorkspaceRuntime { + pub(super) fn new( + runtime: RuntimeConfig, + workspace_directory_path: PathBuf, + expanded_isolation: ExpandedIsolationConfig, + host_opencode_command: String, + container_opencode_command: String, + ) -> Self { + let context = RuntimeContext { + runtime: runtime.clone(), + workspace_directory_path, + expanded_isolation, + host_opencode_command, + container_opencode_command, + }; + match runtime.backend { + RuntimeBackend::LinuxSystemdBwrap => Self::Linux(LinuxSystemdBwrapRuntime { context }), + RuntimeBackend::AppleContainer => { + Self::AppleContainer(AppleContainerRuntime { context }) + } + } + } + + pub(super) async fn start_server( + &self, + key: &str, + inherited_env: &[(String, String)], + ) -> Result { + match self { + Self::Linux(runtime) => runtime.start_server(key, inherited_env).await, + Self::AppleContainer(runtime) => runtime.start_server(key, inherited_env).await, + } + } + + pub(super) async fn stop_server( + &self, + runtime_handle: &RuntimeHandleSnapshot, + ) -> Result<(), CombinedServiceError> { + match runtime_handle.backend { + RuntimeBackend::LinuxSystemdBwrap => { + LinuxSystemdBwrapRuntime::stop_server(runtime_handle).await + } + RuntimeBackend::AppleContainer => { + AppleContainerRuntime::stop_server(runtime_handle).await + } + } + } + + pub(super) async fn build_pty_command( + &self, + key: &str, + inherited_env: &[(String, String)], + command: Vec, + ) -> Result { + match self { + Self::Linux(runtime) => runtime.build_pty_command(key, inherited_env, command).await, + Self::AppleContainer(runtime) => { + runtime.build_pty_command(key, inherited_env, command).await + } + } + } + + pub(super) async fn build_linux_start_command( + &self, + key: &str, + password: &str, + port: u16, + unit: &str, + inherited_env: &[(String, String)], + ) -> Result { + match self { + Self::Linux(runtime) => { + runtime + .build_systemd_bwrap_command(key, password, port, unit, inherited_env) + .await + } + Self::AppleContainer(_) => Err(CombinedServiceError::UnsupportedRuntimeBackend( + "build_systemd_bwrap_command is only available for the linux-systemd-bwrap backend" + .to_string(), + )), + } + } + + pub(super) async fn read_activity(runtime_handle: &RuntimeHandleSnapshot) -> RuntimeActivity { + match runtime_handle.backend { + RuntimeBackend::LinuxSystemdBwrap => { + LinuxSystemdBwrapRuntime::read_activity(runtime_handle).await + } + RuntimeBackend::AppleContainer => { + AppleContainerRuntime::read_activity(runtime_handle).await + } + } + } + + pub(super) async fn read_usage(runtime_handle: &RuntimeHandleSnapshot) -> RuntimeUsageSample { + match runtime_handle.backend { + RuntimeBackend::LinuxSystemdBwrap => { + LinuxSystemdBwrapRuntime::read_usage(runtime_handle).await + } + RuntimeBackend::AppleContainer => { + AppleContainerRuntime::read_usage(runtime_handle).await + } + } + } + + pub(super) fn backend(&self) -> RuntimeBackend { + match self { + Self::Linux(_) => RuntimeBackend::LinuxSystemdBwrap, + Self::AppleContainer(_) => RuntimeBackend::AppleContainer, + } + } + + pub(super) fn runtime_spec(&self) -> String { + let context = match self { + Self::Linux(runtime) => &runtime.context, + Self::AppleContainer(runtime) => &runtime.context, + }; + + let mut parts = vec![ + format!("backend={:?}", context.runtime.backend), + format!( + "image={}", + context.runtime.image.as_deref().unwrap_or_default() + ), + format!("host-opencode={}", context.host_opencode_command), + format!("container-opencode={}", context.container_opencode_command), + format!( + "readable={}", + format_path_list(&context.expanded_isolation.readable) + ), + format!( + "writable={}", + format_path_list(&context.expanded_isolation.writable) + ), + format!( + "isolated={}", + format_path_list(&context.expanded_isolation.isolated) + ), + format!( + "tmpfs={}", + format_path_list(&context.expanded_isolation.tmpfs) + ), + format!( + "skills={}", + format_skill_mounts(&context.expanded_isolation.added_skills) + ), + format!( + "inherit-env={}", + context.expanded_isolation.inherit_env.join(",") + ), + format!( + "memory-high={}", + context + .expanded_isolation + .memory_high_bytes + .map(|value| value.to_string()) + .unwrap_or_default() + ), + format!( + "memory-max={}", + context + .expanded_isolation + .memory_max_bytes + .map(|value| value.to_string()) + .unwrap_or_default() + ), + format!( + "cpu={}", + context + .expanded_isolation + .cpu + .as_deref() + .unwrap_or_default() + ), + ]; + parts.push(format!( + "workspace-root={}", + context.workspace_directory_path.to_string_lossy() + )); + parts.join("\n") + } +} + +#[derive(Debug, Clone)] +pub(super) struct LinuxSystemdBwrapRuntime { + context: RuntimeContext, +} + +impl LinuxSystemdBwrapRuntime { + async fn start_server( + &self, + key: &str, + inherited_env: &[(String, String)], + ) -> Result { + let password = generate_random_password(); + let port = pick_random_free_port().await?; + let unit = generate_linux_runtime_id(); + let command = self + .build_systemd_bwrap_command(key, &password, port, &unit, inherited_env) + .await?; + let mut process = Command::new(&command.program); + process + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .args(&command.args); + for (name, value) in &command.inherited_env { + process.env(name, value); + } + let output = process.output().await?; + + if !output.status.success() { + return Err(CombinedServiceError::StartWorkspaceFailed { + status: output.status.code(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }); + } + + Ok(RuntimeStartResult { + transient: TransientWorkspaceSnapshot { + uri: format!("http://opencode:{password}@127.0.0.1:{port}/"), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::LinuxSystemdBwrap, + id: unit, + metadata: BTreeMap::from([( + RUNTIME_SPEC_METADATA_KEY.to_string(), + WorkspaceRuntime::Linux(self.clone()).runtime_spec(), + )]), + }, + }, + }) + } + + async fn stop_server( + runtime_handle: &RuntimeHandleSnapshot, + ) -> Result<(), CombinedServiceError> { + let args = vec![ + "--user".to_string(), + "stop".to_string(), + "--no-block".to_string(), + runtime_handle.id.clone(), + ]; + let output = Command::new("systemctl") + .args(args) + .stdin(Stdio::null()) + .output() + .await?; + if output.status.success() { + Ok(()) + } else { + Err(CombinedServiceError::StopWorkspaceFailed { + status: output.status.code(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }) + } + } + + async fn build_pty_command( + &self, + key: &str, + inherited_env: &[(String, String)], + command: Vec, + ) -> Result { + let unit = generate_linux_runtime_id(); + let mut args = vec![ + "--user".to_string(), + "--wait".to_string(), + "--collect".to_string(), + "--pty".to_string(), + ]; + append_systemd_run_inherit_env(&mut args, inherited_env); + args.push("--unit".to_string()); + args.push(unit); + self.append_systemd_limits(&mut args); + self.append_bwrap_sandbox_args(&mut args, key).await?; + args.extend(command); + + Ok(SpawnCommand { + program: "systemd-run".to_string(), + args, + inherited_env: inherited_env.to_vec(), + }) + } + + async fn build_systemd_bwrap_command( + &self, + key: &str, + password: &str, + port: u16, + unit: &str, + inherited_env: &[(String, String)], + ) -> Result { + let mut args = vec!["--user".to_string(), "--no-block".to_string()]; + let mut env = inherited_env.to_vec(); + env.push(( + "OPENCODE_SERVER_USERNAME".to_string(), + "opencode".to_string(), + )); + env.push(("OPENCODE_SERVER_PASSWORD".to_string(), password.to_string())); + append_systemd_run_inherit_env(&mut args, &env); + args.push("--unit".to_string()); + args.push(unit.to_string()); + self.append_systemd_limits(&mut args); + + self.append_bwrap_sandbox_args(&mut args, key).await?; + args.push(self.context.host_opencode_command.clone()); + args.push("serve".to_string()); + args.push("--hostname".to_string()); + args.push("127.0.0.1".to_string()); + args.push("--port".to_string()); + args.push(port.to_string()); + + Ok(SpawnCommand { + program: "systemd-run".to_string(), + args, + inherited_env: env, + }) + } + + fn append_systemd_limits(&self, args: &mut Vec) { + if let Some(memory_high_bytes) = self.context.expanded_isolation.memory_high_bytes { + args.push("-p".to_string()); + args.push(format!("MemoryHigh={memory_high_bytes}")); + } + if let Some(memory_max_bytes) = self.context.expanded_isolation.memory_max_bytes { + args.push("-p".to_string()); + args.push(format!("MemoryMax={memory_max_bytes}")); + args.push("-p".to_string()); + args.push("MemorySwapMax=0".to_string()); + } + if let Some(cpu) = &self.context.expanded_isolation.cpu { + args.push("-p".to_string()); + args.push(format!("CPUQuota={cpu}")); + } + } + + async fn append_bwrap_sandbox_args( + &self, + args: &mut Vec, + key: &str, + ) -> Result<(), CombinedServiceError> { + let workspace_path = self.context.workspace_directory_path.join(key); + let workspace_path_str = workspace_path.to_string_lossy().into_owned(); + + args.push("bwrap".to_string()); + args.push("--chdir".to_string()); + args.push(workspace_path_str.clone()); + + args.push("--ro-bind".to_string()); + args.push("/".to_string()); + args.push("/".to_string()); + + let mut mount_specs = Vec::new(); + mount_specs.extend( + self.context + .expanded_isolation + .readable + .iter() + .cloned() + .map(|path| MountSpec::new(path, None, MountKind::Readable)), + ); + mount_specs.extend( + self.context + .expanded_isolation + .writable + .iter() + .cloned() + .map(|path| MountSpec::new(path.clone(), Some(path), MountKind::Writable)), + ); + mount_specs.push(MountSpec::new( + workspace_path.clone(), + Some(workspace_path.clone()), + MountKind::Writable, + )); + mount_specs.extend( + self.context + .expanded_isolation + .isolated + .iter() + .cloned() + .map(|path| { + let source = self.isolated_storage_path(key, &path); + MountSpec::new(path.clone(), Some(source), MountKind::Isolated) + }), + ); + mount_specs.extend( + self.context + .expanded_isolation + .tmpfs + .iter() + .cloned() + .map(|path| MountSpec::new(path, None, MountKind::Tmpfs)), + ); + mount_specs.extend( + self.context + .expanded_isolation + .added_skills + .iter() + .cloned() + .map(|mount| MountSpec::new(mount.target, Some(mount.source), MountKind::Readable)), + ); + mount_specs.sort_by(|a, b| { + a.depth() + .cmp(&b.depth()) + .then_with(|| a.target.cmp(&b.target)) + .then_with(|| a.kind.cmp(&b.kind)) + }); + + let mut resolved_mounts = Vec::with_capacity(mount_specs.len()); + for (index, mount_spec) in mount_specs.iter().enumerate() { + let resolved_mount = mount_spec.resolve_effective(&resolved_mounts); + let owns_node = !mount_specs.iter().skip(index + 1).any(|other| { + other.target.starts_with(&mount_spec.target) && other.target != mount_spec.target + }); + let owns_source_node = owns_node + || (mount_spec.is_file + && mount_spec + .source + .as_ref() + .is_some_and(|source| source != &resolved_mount.effective_source)); + resolved_mount.prepare_source_node(owns_source_node).await?; + resolved_mounts.push(resolved_mount); + } + + for resolved_mount in resolved_mounts { + resolved_mount.append_args(args); + } + + args.push("--proc".to_string()); + args.push("/proc".to_string()); + args.push("--dev".to_string()); + args.push("/dev".to_string()); + args.push("--die-with-parent".to_string()); + + Ok(()) + } + + fn isolated_storage_path(&self, key: &str, target: &Path) -> PathBuf { + let relative = target + .strip_prefix("/") + .expect("isolated path is validated as absolute"); + self.context + .workspace_directory_path + .join(".multicode") + .join("isolate") + .join(key) + .join(relative) + } + + async fn read_activity(runtime_handle: &RuntimeHandleSnapshot) -> RuntimeActivity { + let output = match Command::new("systemctl") + .args([ + "--user", + "show", + runtime_handle.id.as_str(), + "--property", + "ActiveState", + "--value", + ]) + .stdin(Stdio::null()) + .output() + .await + { + Ok(output) => output, + Err(_) => return RuntimeActivity::Unknown, + }; + + if !output.status.success() { + return RuntimeActivity::Stopped; + } + + let state = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if matches!(state.as_str(), "active" | "activating") { + RuntimeActivity::Active + } else { + RuntimeActivity::Stopped + } + } + + async fn read_usage(runtime_handle: &RuntimeHandleSnapshot) -> RuntimeUsageSample { + let output = match Command::new("systemctl") + .args([ + "--user", + "show", + runtime_handle.id.as_str(), + "--property", + "ActiveState", + "--property", + "MemoryCurrent", + "--property", + "CPUUsageNSec", + ]) + .stdin(Stdio::null()) + .output() + .await + { + Ok(output) => output, + Err(_) => { + return RuntimeUsageSample { + state: Some(RuntimeUsageState::Unknown), + ..Default::default() + }; + } + }; + + if !output.status.success() { + return RuntimeUsageSample { + state: Some(RuntimeUsageState::Stopped), + ..Default::default() + }; + } + + parse_linux_unit_usage(&String::from_utf8_lossy(&output.stdout)) + } +} + +#[derive(Debug, Clone)] +pub(super) struct AppleContainerRuntime { + context: RuntimeContext, +} + +impl AppleContainerRuntime { + async fn start_server( + &self, + key: &str, + inherited_env: &[(String, String)], + ) -> Result { + let password = generate_random_password(); + let port = pick_random_free_port().await?; + let container_name = self.container_name_for_key(key); + self.remove_container_if_present(&container_name).await?; + let command = self + .build_run_command(key, &container_name, &password, port, inherited_env) + .await?; + + let output = run_blocking_process(command.program.clone(), command.args.clone()).await?; + + if !output.status.success() { + return Err(CombinedServiceError::StartWorkspaceFailed { + status: output.status.code(), + stderr: String::from_utf8_lossy(&output.stderr).into_owned(), + }); + } + + let mut metadata = BTreeMap::new(); + metadata.insert("workspace-key".to_string(), key.to_string()); + metadata.insert("port".to_string(), port.to_string()); + metadata.insert( + RUNTIME_SPEC_METADATA_KEY.to_string(), + WorkspaceRuntime::AppleContainer(self.clone()).runtime_spec(), + ); + + Ok(RuntimeStartResult { + transient: TransientWorkspaceSnapshot { + uri: format!("http://opencode:{password}@127.0.0.1:{port}/"), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::AppleContainer, + id: container_name, + metadata, + }, + }, + }) + } + + async fn remove_container_if_present( + &self, + container_name: &str, + ) -> Result<(), CombinedServiceError> { + let output = run_blocking_process( + container_program(), + vec![ + "rm".to_string(), + "-f".to_string(), + container_name.to_string(), + ], + ) + .await?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + if container_delete_reports_missing(&stderr) { + return Ok(()); + } + tracing::warn!( + container_name, + status = output.status.code(), + stderr = %stderr, + "best-effort apple container preflight delete failed; continuing startup" + ); + Ok(()) + } + + async fn stop_server( + runtime_handle: &RuntimeHandleSnapshot, + ) -> Result<(), CombinedServiceError> { + let output = run_blocking_process( + container_program(), + vec![ + "rm".to_string(), + "-f".to_string(), + runtime_handle.id.clone(), + ], + ) + .await?; + let stderr = String::from_utf8_lossy(&output.stderr); + if output.status.success() || container_delete_reports_missing(&stderr) { + Ok(()) + } else { + Err(CombinedServiceError::StopWorkspaceFailed { + status: output.status.code(), + stderr: stderr.into_owned(), + }) + } + } + + async fn build_pty_command( + &self, + key: &str, + inherited_env: &[(String, String)], + command: Vec, + ) -> Result { + let image = self.context.runtime.image.as_deref().ok_or_else(|| { + CombinedServiceError::InvalidRuntimeConfig { + field: "runtime.image".to_string(), + message: "apple-container backend requires a runtime image".to_string(), + } + })?; + let env_file = self.write_env_file(key, "exec.env", inherited_env).await?; + let workspace_path = self.context.workspace_directory_path.join(key); + let mut args = vec![ + "run".to_string(), + "--rm".to_string(), + "--tty".to_string(), + "--interactive".to_string(), + "--env-file".to_string(), + env_file.to_string_lossy().into_owned(), + "--workdir".to_string(), + workspace_path.to_string_lossy().into_owned(), + ]; + self.append_container_limits(&mut args); + self.append_container_mounts(args.as_mut(), key).await?; + args.push(image.to_string()); + args.extend(command); + + Ok(SpawnCommand { + program: container_program(), + args, + inherited_env: Vec::new(), + }) + } + + async fn read_activity(runtime_handle: &RuntimeHandleSnapshot) -> RuntimeActivity { + let inspect_output = run_blocking_process( + container_program(), + vec!["inspect".to_string(), runtime_handle.id.clone()], + ) + .await; + if let Ok(output) = inspect_output { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + if stdout.contains(r#""status":"running""#) { + return RuntimeActivity::Active; + } + if stdout.contains(r#""status":"stopped""#) + || stdout.contains(r#""status":"exited""#) + { + return RuntimeActivity::Stopped; + } + } else { + return RuntimeActivity::Stopped; + } + } + + let output = match run_blocking_process(container_program(), vec!["list".to_string()]).await + { + Ok(output) => output, + Err(_) => return RuntimeActivity::Unknown, + }; + if !output.status.success() { + return RuntimeActivity::Unknown; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + if stdout + .lines() + .any(|line| line.contains(runtime_handle.id.as_str())) + { + RuntimeActivity::Active + } else { + RuntimeActivity::Stopped + } + } + + async fn read_usage(_runtime_handle: &RuntimeHandleSnapshot) -> RuntimeUsageSample { + RuntimeUsageSample { + state: Some(RuntimeUsageState::Unknown), + ..Default::default() + } + } + + async fn build_run_command( + &self, + key: &str, + container_name: &str, + password: &str, + port: u16, + inherited_env: &[(String, String)], + ) -> Result { + let image = self.context.runtime.image.as_deref().ok_or_else(|| { + CombinedServiceError::InvalidRuntimeConfig { + field: "runtime.image".to_string(), + message: "apple-container backend requires a runtime image".to_string(), + } + })?; + let mut env = inherited_env.to_vec(); + env.push(( + "OPENCODE_SERVER_USERNAME".to_string(), + "opencode".to_string(), + )); + env.push(("OPENCODE_SERVER_PASSWORD".to_string(), password.to_string())); + + let workspace_path = self.context.workspace_directory_path.join(key); + tokio::fs::create_dir_all(&workspace_path).await?; + let env_file = self.write_env_file(key, "server.env", &env).await?; + + let mut args = vec![ + "run".to_string(), + "--detach".to_string(), + "--rm".to_string(), + "--name".to_string(), + container_name.to_string(), + "--env-file".to_string(), + env_file.to_string_lossy().into_owned(), + "--workdir".to_string(), + workspace_path.to_string_lossy().into_owned(), + "--publish".to_string(), + format!("127.0.0.1:{port}:{port}/tcp"), + ]; + self.append_container_limits(&mut args); + self.append_container_mounts(&mut args, key).await?; + args.push(image.to_string()); + args.push(self.context.container_opencode_command.clone()); + args.push("serve".to_string()); + args.push("--hostname".to_string()); + args.push("0.0.0.0".to_string()); + args.push("--port".to_string()); + args.push(port.to_string()); + + Ok(SpawnCommand { + program: container_program(), + args, + inherited_env: Vec::new(), + }) + } + + fn append_container_limits(&self, args: &mut Vec) { + if let Some(cpu) = self + .context + .expanded_isolation + .cpu + .as_deref() + .and_then(container_cpu_value) + { + args.push("--cpus".to_string()); + args.push(cpu); + } + + let memory_limit = self + .context + .expanded_isolation + .memory_max_bytes + .or(self.context.expanded_isolation.memory_high_bytes); + if let Some(memory_limit) = memory_limit { + args.push("--memory".to_string()); + args.push(memory_limit.to_string()); + } + } + + async fn append_container_mounts( + &self, + args: &mut Vec, + key: &str, + ) -> Result<(), CombinedServiceError> { + let workspace_path = self.context.workspace_directory_path.join(key); + let mut mount_specs = Vec::new(); + mount_specs.extend( + self.context + .expanded_isolation + .readable + .iter() + .cloned() + .map(|path| MountSpec::new(path, None, MountKind::Readable)), + ); + mount_specs.extend( + self.context + .expanded_isolation + .writable + .iter() + .cloned() + .map(|path| MountSpec::new(path.clone(), Some(path), MountKind::Writable)), + ); + mount_specs.push(MountSpec::new( + workspace_path.clone(), + Some(workspace_path.clone()), + MountKind::Writable, + )); + mount_specs.extend( + self.context + .expanded_isolation + .isolated + .iter() + .cloned() + .map(|path| { + let source = self.isolated_storage_path(key, &path); + MountSpec::new(path.clone(), Some(source), MountKind::Isolated) + }), + ); + mount_specs.extend( + self.context + .expanded_isolation + .tmpfs + .iter() + .cloned() + .map(|path| MountSpec::new(path, None, MountKind::Tmpfs)), + ); + if let Some(skill_mount) = self.build_aggregated_skill_mount(key).await? { + mount_specs.push(skill_mount); + } else { + mount_specs.extend( + self.context + .expanded_isolation + .added_skills + .iter() + .cloned() + .map(|mount| { + MountSpec::new(mount.target, Some(mount.source), MountKind::Readable) + }), + ); + } + mount_specs.sort_by(|a, b| { + a.depth() + .cmp(&b.depth()) + .then_with(|| a.target.cmp(&b.target)) + .then_with(|| a.kind.cmp(&b.kind)) + }); + + let mut resolved_mounts = Vec::with_capacity(mount_specs.len()); + for (index, mount_spec) in mount_specs.iter().enumerate() { + let resolved_mount = mount_spec.resolve_effective(&resolved_mounts); + let owns_node = !mount_specs.iter().skip(index + 1).any(|other| { + other.target.starts_with(&mount_spec.target) && other.target != mount_spec.target + }); + let owns_source_node = owns_node + || (mount_spec.is_file + && mount_spec + .source + .as_ref() + .is_some_and(|source| source != &resolved_mount.effective_source)); + resolved_mount.prepare_source_node(owns_source_node).await?; + resolved_mount.prepare_target_node(owns_node).await?; + resolved_mount.prepare_container_materialized_file().await?; + resolved_mounts.push(resolved_mount); + } + + for resolved_mount in resolved_mounts { + resolved_mount.append_container_args(args); + } + + Ok(()) + } + + async fn build_aggregated_skill_mount( + &self, + key: &str, + ) -> Result, CombinedServiceError> { + let added_skills = &self.context.expanded_isolation.added_skills; + if added_skills.is_empty() { + return Ok(None); + } + + let Some(target_root) = added_skills + .first() + .and_then(|mount| mount.target.parent()) + .map(Path::to_path_buf) + else { + return Ok(None); + }; + + if added_skills + .iter() + .any(|mount| mount.target.parent() != Some(target_root.as_path())) + { + return Ok(None); + } + + let aggregate_root = self.apple_runtime_root(key).join("skills"); + match tokio::fs::remove_dir_all(&aggregate_root).await { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + tokio::fs::create_dir_all(&aggregate_root).await?; + + if tokio::fs::metadata(&target_root).await.is_ok() { + copy_directory_tree(&target_root, &aggregate_root).await?; + } + + for skill in added_skills { + let Some(skill_name) = skill.target.file_name() else { + continue; + }; + copy_directory_tree(&skill.source, &aggregate_root.join(skill_name)).await?; + } + + Ok(Some(MountSpec::new( + target_root, + Some(aggregate_root), + MountKind::Readable, + ))) + } + + async fn write_env_file( + &self, + key: &str, + file_name: &str, + env: &[(String, String)], + ) -> Result { + let runtime_root = self.apple_runtime_root(key); + tokio::fs::create_dir_all(&runtime_root).await?; + let path = runtime_root.join(file_name); + let mut content = String::new(); + for (name, value) in env { + if value.contains('\n') || value.contains('\r') { + return Err(CombinedServiceError::InvalidRuntimeConfig { + field: "runtime.env-file".to_string(), + message: format!( + "environment variable '{name}' contains newlines and cannot be written to a container env file" + ), + }); + } + content.push_str(name); + content.push('='); + content.push_str(value); + content.push('\n'); + } + tokio::fs::write(&path, content).await?; + Ok(path) + } + + fn apple_runtime_root(&self, key: &str) -> PathBuf { + self.context + .workspace_directory_path + .join(".multicode") + .join("apple-container") + .join(key) + } + + fn isolated_storage_path(&self, key: &str, target: &Path) -> PathBuf { + let relative = target + .strip_prefix("/") + .expect("isolated path is validated as absolute"); + self.apple_runtime_root(key).join("isolate").join(relative) + } + + fn container_name_for_key(&self, key: &str) -> String { + format!("multicode-{}", key) + } +} + +fn format_path_list(paths: &[PathBuf]) -> String { + paths + .iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect::>() + .join(",") +} + +fn format_skill_mounts(skills: &[super::config::AddedSkillMount]) -> String { + let mut pairs = skills + .iter() + .map(|skill| { + format!( + "{}=>{}", + skill.source.to_string_lossy(), + skill.target.to_string_lossy() + ) + }) + .collect::>(); + pairs.sort(); + pairs.join(",") +} + +fn append_systemd_run_inherit_env(args: &mut Vec, env: &[(String, String)]) { + for (name, _) in env { + args.push("--setenv".to_string()); + args.push(name.clone()); + } +} + +fn generate_random_password() -> String { + Uuid::new_v4().as_simple().to_string() +} + +fn generate_linux_runtime_id() -> String { + format!("multicode-{}.service", Uuid::new_v4().as_simple()) +} + +async fn pick_random_free_port() -> Result { + if let Some(port) = std::env::var_os("MULTICODE_FIXED_PORT") { + let port = port.to_string_lossy(); + let parsed = + port.parse::() + .map_err(|err| CombinedServiceError::InvalidRuntimeConfig { + field: "MULTICODE_FIXED_PORT".to_string(), + message: err.to_string(), + })?; + return Ok(parsed); + } + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await?; + let port = listener.local_addr()?.port(); + drop(listener); + Ok(port) +} + +fn parse_linux_unit_usage(output: &str) -> RuntimeUsageSample { + let mut active_state: Option<&str> = None; + let mut memory_current: Option = None; + let mut cpu_usage_nsec: Option = None; + + for line in output.lines() { + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let value = value.trim(); + match key.trim() { + "ActiveState" => active_state = Some(value), + "MemoryCurrent" => memory_current = parse_systemctl_u64(value), + "CPUUsageNSec" => cpu_usage_nsec = parse_systemctl_u64(value), + _ => {} + } + } + + let state = match active_state.unwrap_or_default() { + "active" | "activating" => RuntimeUsageState::Active, + "" => RuntimeUsageState::Unknown, + _ => RuntimeUsageState::Stopped, + }; + + RuntimeUsageSample { + memory_current, + cpu_usage_nsec, + state: Some(state), + } +} + +fn parse_systemctl_u64(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed == "[not set]" { + return None; + } + trimmed.parse::().ok() +} + +fn container_cpu_value(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + return None; + } + let cpus = if let Some(percent) = value.strip_suffix('%') { + percent.trim().parse::().ok()? / 100.0 + } else { + value.parse::().ok()? + }; + if !cpus.is_finite() || cpus <= 0.0 { + return None; + } + Some(cpus.ceil().max(1.0).to_string()) +} + +fn container_program() -> String { + std::env::var("MULTICODE_CONTAINER_COMMAND").unwrap_or_else(|_| "container".to_string()) +} + +fn container_delete_reports_missing(stderr: &str) -> bool { + let stderr = stderr.to_ascii_lowercase(); + stderr.contains("not found") + || stderr.contains("no such") + || stderr.contains("no matching containers") + || stderr.contains("does not exist") +} + +async fn run_blocking_process( + program: String, + args: Vec, +) -> Result { + tokio::task::spawn_blocking(move || { + std::process::Command::new(program) + .args(args) + .stdin(Stdio::null()) + .output() + }) + .await + .map_err(|err| std::io::Error::other(err.to_string()))? +} + +async fn copy_directory_tree(source: &Path, target: &Path) -> Result<(), std::io::Error> { + let mut pending = vec![(source.to_path_buf(), target.to_path_buf())]; + + while let Some((source_dir, target_dir)) = pending.pop() { + tokio::fs::create_dir_all(&target_dir).await?; + let mut entries = tokio::fs::read_dir(&source_dir).await?; + while let Some(entry) = entries.next_entry().await? { + let source_path = entry.path(); + let target_path = target_dir.join(entry.file_name()); + let metadata = tokio::fs::metadata(&source_path).await?; + if metadata.is_dir() { + pending.push((source_path, target_path)); + } else if metadata.is_file() { + tokio::fs::copy(&source_path, &target_path).await?; + } + } + } + + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum MountKind { + Readable, + Writable, + Isolated, + Tmpfs, +} + +#[derive(Debug, Clone)] +pub(crate) struct MountSpec { + target: PathBuf, + source: Option, + kind: MountKind, + is_file: bool, +} + +impl MountSpec { + pub(crate) fn new(target: PathBuf, source: Option, kind: MountKind) -> Self { + let is_file = match source.as_ref() { + Some(source) => std::fs::metadata(source) + .map(|metadata| metadata.is_file()) + .unwrap_or_else(|_| { + std::fs::metadata(&target) + .map(|metadata| metadata.is_file()) + .unwrap_or_else(|_| { + path_looks_like_file(source) || path_looks_like_file(&target) + }) + }), + None => std::fs::metadata(&target) + .map(|metadata| metadata.is_file()) + .unwrap_or_else(|_| path_looks_like_file(&target)), + }; + Self { + target, + source, + kind, + is_file, + } + } + + fn depth(&self) -> usize { + self.target.components().count() + } + + fn resolve_backing_mount<'a>( + path: &Path, + prior_mounts: &'a [ResolvedMountSpec], + ) -> Option<&'a ResolvedMountSpec> { + prior_mounts.iter().rev().find(|prior_mount| { + path == prior_mount.mount.target || path.starts_with(&prior_mount.mount.target) + }) + } + + fn resolve_backing_path(path: &Path, prior_mounts: &[ResolvedMountSpec]) -> PathBuf { + if let Some(prior_mount) = Self::resolve_backing_mount(path, prior_mounts) { + let relative = path + .strip_prefix(&prior_mount.mount.target) + .expect("path should be under prior mount target"); + prior_mount.effective_source.join(relative) + } else { + path.to_path_buf() + } + } + + pub(crate) fn resolve_effective( + &self, + prior_mounts: &[ResolvedMountSpec], + ) -> ResolvedMountSpec { + let backing_mount_kind = + Self::resolve_backing_mount(&self.target, prior_mounts).map(|mount| mount.mount.kind); + let effective_target = Self::resolve_backing_path(&self.target, prior_mounts); + let effective_source = match self.kind { + MountKind::Isolated => self + .source + .as_ref() + .map(|source| Self::resolve_backing_path(source, prior_mounts)) + .unwrap_or_else(|| effective_target.clone()), + MountKind::Readable | MountKind::Writable => { + self.source.clone().unwrap_or_else(|| self.target.clone()) + } + MountKind::Tmpfs => effective_target.clone(), + }; + ResolvedMountSpec { + mount: self.clone(), + backing_mount_kind, + effective_target, + effective_source, + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct ResolvedMountSpec { + mount: MountSpec, + backing_mount_kind: Option, + effective_target: PathBuf, + effective_source: PathBuf, +} + +impl ResolvedMountSpec { + pub(crate) async fn prepare_source_node( + &self, + owns_node: bool, + ) -> Result<(), CombinedServiceError> { + self.prepare_node( + &self.effective_source, + owns_node, + self.mount + .source + .as_ref() + .filter(|original| *original != &self.effective_source), + ) + .await + } + + pub(crate) async fn prepare_target_node( + &self, + owns_node: bool, + ) -> Result<(), CombinedServiceError> { + let should_materialize = if self.mount.is_file { owns_node } else { true }; + self.prepare_node(&self.effective_target, should_materialize, None) + .await + } + + pub(crate) async fn prepare_container_materialized_file( + &self, + ) -> Result<(), CombinedServiceError> { + if !self.should_materialize_container_file() { + return Ok(()); + } + + if let Some(parent) = self.effective_target.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + if tokio::fs::metadata(&self.effective_source).await.is_ok() { + tokio::fs::copy(&self.effective_source, &self.effective_target).await?; + } else if tokio::fs::metadata(&self.effective_target).await.is_err() { + tokio::fs::File::create(&self.effective_target).await?; + } + + Ok(()) + } + + async fn prepare_node( + &self, + path: &Path, + materialize_node: bool, + seed_file: Option<&PathBuf>, + ) -> Result<(), CombinedServiceError> { + if self.mount.is_file { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + if materialize_node && tokio::fs::metadata(path).await.is_err() { + if let Some(seed_file) = seed_file { + if tokio::fs::metadata(seed_file).await.is_ok() { + tokio::fs::copy(seed_file, path).await?; + return Ok(()); + } + } + tokio::fs::File::create(path).await?; + } + } else if materialize_node { + tokio::fs::create_dir_all(path).await?; + } else if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + Ok(()) + } + + fn append_args(&self, args: &mut Vec) { + match self.mount.kind { + MountKind::Readable => { + args.push("--ro-bind".to_string()); + args.push(self.effective_source.to_string_lossy().into_owned()); + args.push(self.mount.target.to_string_lossy().into_owned()); + } + MountKind::Writable | MountKind::Isolated => { + args.push("--bind".to_string()); + args.push(self.effective_source.to_string_lossy().into_owned()); + args.push(self.mount.target.to_string_lossy().into_owned()); + } + MountKind::Tmpfs => { + args.push("--tmpfs".to_string()); + args.push(self.mount.target.to_string_lossy().into_owned()); + } + } + } + + fn append_container_args(&self, args: &mut Vec) { + if self.should_materialize_container_file() { + return; + } + + match self.mount.kind { + MountKind::Tmpfs => { + args.push("--tmpfs".to_string()); + args.push(self.mount.target.to_string_lossy().into_owned()); + } + MountKind::Readable | MountKind::Writable | MountKind::Isolated => { + args.push("--mount".to_string()); + let mut mount = format!( + "type=bind,source={},target={}", + self.effective_source.to_string_lossy(), + self.mount.target.to_string_lossy() + ); + if matches!(self.mount.kind, MountKind::Readable) { + mount.push_str(",readonly"); + } + args.push(mount); + } + } + } + + fn should_materialize_container_file(&self) -> bool { + self.mount.kind == MountKind::Readable + && self.mount.is_file + && self.backing_mount_kind == Some(MountKind::Isolated) + && self.effective_target != self.mount.target + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::services::config::{AddedSkillMount, IsolationConfig}; + use std::fs; + + struct TestDir { + path: PathBuf, + } + + impl TestDir { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "multicode-runtime-test-{}-{}", + std::process::id(), + Uuid::new_v4().as_simple() + )); + fs::create_dir_all(&path).expect("test dir should be created"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } + } + + fn apple_runtime(root: &TestDir, isolation: IsolationConfig) -> AppleContainerRuntime { + let expanded_isolation = + ExpandedIsolationConfig::from_config(&isolation, None).expect("config should expand"); + AppleContainerRuntime { + context: RuntimeContext { + runtime: RuntimeConfig { + backend: RuntimeBackend::AppleContainer, + image: Some("ghcr.io/example/multicode-java25:latest".to_string()), + }, + workspace_directory_path: root.path().join("workspaces"), + expanded_isolation, + host_opencode_command: "/opt/opencode/bin/opencode".to_string(), + container_opencode_command: "opencode".to_string(), + }, + } + } + + fn contains_sequence(args: &[String], sequence: &[&str]) -> bool { + args.windows(sequence.len()).any(|window| { + window + .iter() + .map(String::as_str) + .eq(sequence.iter().copied()) + }) + } + + #[test] + fn container_cpu_value_converts_percent_to_cpu_count() { + assert_eq!(container_cpu_value("300%"), Some("3".to_string())); + assert_eq!(container_cpu_value("150%"), Some("2".to_string())); + assert_eq!(container_cpu_value("2"), Some("2".to_string())); + assert_eq!(container_cpu_value("1.5"), Some("2".to_string())); + assert_eq!(container_cpu_value(""), None); + } + + #[test] + fn container_delete_reports_missing_matches_common_container_rm_errors() { + assert!(container_delete_reports_missing( + "Error: failed to delete one or more containers: [\"multicode-alpha\"]: no matching containers found" + )); + assert!(container_delete_reports_missing( + "Error: container not found" + )); + assert!(container_delete_reports_missing("Error: No such container")); + assert!(!container_delete_reports_missing( + "Error: failed to delete one or more containers: permission denied" + )); + } + + #[test] + fn apple_container_run_command_honors_limits_and_mounts() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_root = root.path().join("workspaces"); + let readable = root.path().join("readonly"); + let writable = root.path().join("writable"); + fs::create_dir_all(&workspace_root).expect("workspace root should exist"); + fs::create_dir_all(&readable).expect("readable should exist"); + fs::create_dir_all(&writable).expect("writable should exist"); + + let runtime = apple_runtime( + &root, + IsolationConfig { + readable: vec![readable.to_string_lossy().into_owned()], + writable: vec![writable.to_string_lossy().into_owned()], + isolated: vec!["/var/tmp".to_string()], + tmpfs: vec!["/tmp".to_string()], + add_skills_from: Vec::new(), + inherit_env: vec!["HOME".to_string()], + memory_high: Some("8 GB".to_string()), + memory_max: Some("10 GB".to_string()), + cpu: Some("300%".to_string()), + }, + ); + + let command = runtime + .build_run_command( + "alpha", + "multicode-alpha", + "secret", + 31337, + &[( + "HOME".to_string(), + root.path().to_string_lossy().into_owned(), + )], + ) + .await + .expect("command should build"); + + assert_eq!(command.program, "container"); + assert!(contains_sequence( + &command.args, + &["run", "--detach", "--rm"] + )); + assert!(contains_sequence( + &command.args, + &["--name", "multicode-alpha"] + )); + assert!(contains_sequence(&command.args, &["--cpus", "3"])); + assert!(contains_sequence( + &command.args, + &["--memory", "10000000000"] + )); + assert!(contains_sequence( + &command.args, + &["--publish", "127.0.0.1:31337:31337/tcp"] + )); + assert!(contains_sequence(&command.args, &["--tmpfs", "/tmp"])); + assert!( + command + .args + .iter() + .any(|arg| arg.contains("type=bind") && arg.contains("readonly")) + ); + assert!( + command + .args + .iter() + .any(|arg| arg.contains("/var/tmp") && arg.contains("type=bind")) + ); + assert!(contains_sequence( + &command.args, + &[ + "ghcr.io/example/multicode-java25:latest", + "opencode", + "serve", + "--hostname", + "0.0.0.0", + "--port", + "31337" + ] + )); + }); + } + + #[test] + fn apple_container_pty_command_uses_one_shot_container_run() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_root = root.path().join("workspaces"); + fs::create_dir_all(workspace_root.join("alpha")).expect("workspace should exist"); + let runtime = apple_runtime(&root, IsolationConfig::default()); + + let command = runtime + .build_pty_command( + "alpha", + &[( + "HOME".to_string(), + root.path().to_string_lossy().into_owned(), + )], + vec!["/bin/bash".to_string()], + ) + .await + .expect("pty command should build"); + + assert_eq!(command.program, "container"); + assert!(contains_sequence( + &command.args, + &["run", "--rm", "--tty", "--interactive"] + )); + assert!(command.args.iter().any(|arg| arg.ends_with("exec.env"))); + assert!( + command + .args + .iter() + .any(|arg| arg == "ghcr.io/example/multicode-java25:latest") + ); + assert!(command.args.iter().any(|arg| arg == "/bin/bash")); + assert!(command.inherited_env.is_empty()); + }); + } + + #[test] + fn apple_container_materializes_nested_readable_file_inside_isolated_mount() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_root = root.path().join("workspaces"); + let home = root.path().join("home"); + let auth_dir = home.join(".local/share/opencode"); + let auth_file = auth_dir.join("auth.json"); + fs::create_dir_all(&workspace_root).expect("workspace root should exist"); + fs::create_dir_all(&auth_dir).expect("auth directory should exist"); + fs::write(&auth_file, r#"{"token":"apple"}"#).expect("auth file should exist"); + + let runtime = apple_runtime( + &root, + IsolationConfig { + readable: vec![auth_file.to_string_lossy().into_owned()], + writable: Vec::new(), + isolated: vec![auth_dir.to_string_lossy().into_owned()], + tmpfs: Vec::new(), + add_skills_from: Vec::new(), + inherit_env: vec!["HOME".to_string()], + memory_high: None, + memory_max: None, + cpu: None, + }, + ); + + let command = runtime + .build_run_command( + "alpha", + "multicode-alpha", + "secret", + 31337, + &[( + "HOME".to_string(), + home.to_string_lossy().into_owned(), + )], + ) + .await + .expect("command should build"); + + let isolated_storage = workspace_root + .join(".multicode") + .join("apple-container") + .join("alpha") + .join("isolate") + .join( + auth_dir + .strip_prefix("/") + .expect("auth directory should be absolute"), + ); + let materialized_auth = isolated_storage.join("auth.json"); + let host_auth_mount = format!( + "type=bind,source={},target={}", + auth_file.to_string_lossy(), + auth_file.to_string_lossy() + ); + let isolated_dir_mount = format!( + "type=bind,source={},target={}", + isolated_storage.to_string_lossy(), + auth_dir.to_string_lossy() + ); + + assert!( + command.args.iter().any(|arg| arg == &isolated_dir_mount), + "isolated parent directory should still be mounted" + ); + assert!( + command.args.iter().all(|arg| arg != &host_auth_mount), + "nested readable file should be materialized into the isolated backing tree instead of emitted as a separate bind mount" + ); + assert_eq!( + fs::read_to_string(&materialized_auth).expect("materialized auth should exist"), + r#"{"token":"apple"}"# + ); + }); + } + + #[test] + fn apple_container_coalesces_added_skills_into_single_mount() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_root = root.path().join("workspaces"); + let skills_root = root.path().join("workspace-skills"); + let skill_one = skills_root.join("skill-one"); + let skill_two = skills_root.join("skill-two"); + let container_skills_target = + root.path().join("container-home/.config/opencode/skills"); + fs::create_dir_all(&workspace_root).expect("workspace root should exist"); + fs::create_dir_all(&skill_one).expect("first skill should exist"); + fs::create_dir_all(&skill_two).expect("second skill should exist"); + fs::write(skill_one.join("SKILL.md"), "# one").expect("first skill file should exist"); + fs::write(skill_two.join("SKILL.md"), "# two").expect("second skill file should exist"); + + let runtime = AppleContainerRuntime { + context: RuntimeContext { + runtime: RuntimeConfig { + backend: RuntimeBackend::AppleContainer, + image: Some("ghcr.io/example/multicode-java25:latest".to_string()), + }, + workspace_directory_path: workspace_root.clone(), + expanded_isolation: ExpandedIsolationConfig { + readable: Vec::new(), + writable: Vec::new(), + isolated: Vec::new(), + tmpfs: Vec::new(), + added_skills: vec![ + AddedSkillMount { + source: skill_one.clone(), + target: container_skills_target.join("skill-one"), + }, + AddedSkillMount { + source: skill_two.clone(), + target: container_skills_target.join("skill-two"), + }, + ], + inherit_env: Vec::new(), + memory_high_bytes: None, + memory_max_bytes: None, + cpu: None, + }, + host_opencode_command: "/opt/opencode/bin/opencode".to_string(), + container_opencode_command: "opencode".to_string(), + }, + }; + + let command = runtime + .build_run_command("alpha", "multicode-alpha", "secret", 31337, &[]) + .await + .expect("command should build"); + + let aggregated_source = workspace_root + .join(".multicode") + .join("apple-container") + .join("alpha") + .join("skills"); + let aggregated_mount = format!( + "type=bind,source={},target={},readonly", + aggregated_source.to_string_lossy(), + container_skills_target.to_string_lossy() + ); + + assert!( + command.args.iter().any(|arg| arg == &aggregated_mount), + "apple backend should mount one aggregated skills directory" + ); + assert!( + command + .args + .iter() + .all(|arg| !arg.contains("container-home/.config/opencode/skills/skill-one")), + "individual skill mounts should be omitted" + ); + assert_eq!( + fs::read_to_string(aggregated_source.join("skill-one/SKILL.md")) + .expect("aggregated skill one should exist"), + "# one" + ); + assert_eq!( + fs::read_to_string(aggregated_source.join("skill-two/SKILL.md")) + .expect("aggregated skill two should exist"), + "# two" + ); + }); + } + + #[test] + fn apple_container_aggregated_skill_mount_preserves_host_skills() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_root = root.path().join("workspaces"); + let host_home = root.path().join("host-home"); + let host_skills_target = host_home.join(".config/opencode/skills"); + let host_skill = host_skills_target.join("host-skill"); + let added_skills_root = root.path().join("workspace-skills"); + let added_skill = added_skills_root.join("workspace-skill"); + fs::create_dir_all(&workspace_root).expect("workspace root should exist"); + fs::create_dir_all(&host_skill).expect("host skill should exist"); + fs::create_dir_all(&added_skill).expect("added skill should exist"); + fs::write(host_skill.join("SKILL.md"), "# host").expect("host skill file should exist"); + fs::write(added_skill.join("SKILL.md"), "# workspace") + .expect("added skill file should exist"); + + let runtime = AppleContainerRuntime { + context: RuntimeContext { + runtime: RuntimeConfig { + backend: RuntimeBackend::AppleContainer, + image: Some("ghcr.io/example/multicode-java25:latest".to_string()), + }, + workspace_directory_path: workspace_root.clone(), + expanded_isolation: ExpandedIsolationConfig { + readable: vec![host_home.join(".config/opencode")], + writable: Vec::new(), + isolated: Vec::new(), + tmpfs: Vec::new(), + added_skills: vec![AddedSkillMount { + source: added_skill.clone(), + target: host_skills_target.join("workspace-skill"), + }], + inherit_env: Vec::new(), + memory_high_bytes: None, + memory_max_bytes: None, + cpu: None, + }, + host_opencode_command: "/opt/opencode/bin/opencode".to_string(), + container_opencode_command: "opencode".to_string(), + }, + }; + + let command = runtime + .build_run_command("alpha", "multicode-alpha", "secret", 31337, &[]) + .await + .expect("command should build"); + + let aggregated_source = workspace_root + .join(".multicode") + .join("apple-container") + .join("alpha") + .join("skills"); + let aggregated_mount = format!( + "type=bind,source={},target={},readonly", + aggregated_source.to_string_lossy(), + host_skills_target.to_string_lossy() + ); + + assert!( + command.args.iter().any(|arg| arg == &aggregated_mount), + "apple backend should expose a merged skills directory" + ); + assert_eq!( + fs::read_to_string(aggregated_source.join("host-skill/SKILL.md")) + .expect("host skill should be preserved"), + "# host" + ); + assert_eq!( + fs::read_to_string(aggregated_source.join("workspace-skill/SKILL.md")) + .expect("workspace skill should be included"), + "# workspace" + ); + }); + } +} diff --git a/lib/src/services/runtime_reconciliation_service.rs b/lib/src/services/runtime_reconciliation_service.rs new file mode 100644 index 0000000..6c4f202 --- /dev/null +++ b/lib/src/services/runtime_reconciliation_service.rs @@ -0,0 +1,159 @@ +use std::sync::Arc; + +use tokio::sync::watch; + +use super::{ + runtime::{RUNTIME_SPEC_METADATA_KEY, WorkspaceRuntime}, + workspace_watch::monitor_workspace_snapshots, +}; +use crate::{ + RuntimeBackend, TransientWorkspaceSnapshot, WorkspaceManager, WorkspaceManagerError, + WorkspaceSnapshot, manager::Workspace, +}; + +#[derive(Debug)] +#[allow(dead_code)] +pub(super) enum RuntimeReconciliationServiceError { + Manager(WorkspaceManagerError), +} + +impl From for RuntimeReconciliationServiceError { + fn from(value: WorkspaceManagerError) -> Self { + Self::Manager(value) + } +} + +pub(super) async fn runtime_reconciliation_service( + manager: Arc, + runtime: WorkspaceRuntime, +) -> Result<(), RuntimeReconciliationServiceError> { + let expected_backend = runtime.backend(); + let expected_spec = runtime.runtime_spec(); + monitor_workspace_snapshots(manager, move |key, workspace, workspace_rx| { + let runtime = runtime.clone(); + let expected_spec = expected_spec.clone(); + async move { + tokio::spawn(async move { + watch_workspace_snapshot( + key, + workspace, + workspace_rx, + runtime, + expected_backend, + expected_spec, + ) + .await; + }); + Ok(()) + } + }) + .await +} + +async fn watch_workspace_snapshot( + key: String, + workspace: Workspace, + mut workspace_rx: watch::Receiver, + runtime: WorkspaceRuntime, + expected_backend: RuntimeBackend, + expected_spec: String, +) { + loop { + let current_transient = workspace_rx.borrow().transient.clone(); + if let Some(transient) = current_transient { + if should_invalidate_runtime(&transient, expected_backend, &expected_spec) { + tracing::info!( + workspace_key = %key, + runtime_id = %transient.runtime.id, + expected_backend = ?expected_backend, + actual_backend = ?transient.runtime.backend, + "stopping stale workspace runtime because the runtime specification changed" + ); + if let Err(err) = runtime.stop_server(&transient.runtime).await { + tracing::warn!( + workspace_key = %key, + runtime_id = %transient.runtime.id, + error = ?err, + "failed to stop stale workspace runtime during reconciliation" + ); + } + workspace.update(|snapshot| { + if snapshot.transient.as_ref() == Some(&transient) { + snapshot.transient = None; + true + } else { + false + } + }); + } + } + + if workspace_rx.changed().await.is_err() { + break; + } + } +} + +fn should_invalidate_runtime( + transient: &TransientWorkspaceSnapshot, + expected_backend: RuntimeBackend, + expected_spec: &str, +) -> bool { + if transient.runtime.backend != expected_backend { + return true; + } + + transient.runtime.backend == RuntimeBackend::AppleContainer + && transient + .runtime + .metadata + .get(RUNTIME_SPEC_METADATA_KEY) + .map(String::as_str) + != Some(expected_spec) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{RuntimeHandleSnapshot, TransientWorkspaceSnapshot}; + use std::collections::BTreeMap; + + #[test] + fn runtime_reconciliation_invalidates_apple_runtime_without_matching_spec() { + let transient = TransientWorkspaceSnapshot { + uri: "http://opencode:secret@127.0.0.1:31337/".to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::AppleContainer, + id: "multicode-alpha".to_string(), + metadata: BTreeMap::new(), + }, + }; + + assert!(should_invalidate_runtime( + &transient, + RuntimeBackend::AppleContainer, + "expected" + )); + } + + #[test] + fn runtime_reconciliation_keeps_apple_runtime_with_matching_spec() { + let transient = TransientWorkspaceSnapshot { + uri: "http://opencode:secret@127.0.0.1:31337/".to_string(), + runtime: RuntimeHandleSnapshot { + backend: RuntimeBackend::AppleContainer, + id: "multicode-alpha".to_string(), + metadata: BTreeMap::from([( + RUNTIME_SPEC_METADATA_KEY.to_string(), + "expected".to_string(), + )]), + }, + }; + + assert!(!should_invalidate_runtime( + &transient, + RuntimeBackend::AppleContainer, + "expected" + )); + } +} diff --git a/lib/tests/apple_container_runtime_integration.rs b/lib/tests/apple_container_runtime_integration.rs new file mode 100644 index 0000000..a06e602 --- /dev/null +++ b/lib/tests/apple_container_runtime_integration.rs @@ -0,0 +1,690 @@ +use std::{ + ffi::OsString, + fs, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + sync::Mutex, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use multicode_lib::{RuntimeBackend, services::CombinedService}; + +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +struct TestDir { + path: PathBuf, +} + +impl TestDir { + fn new() -> Self { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch") + .as_nanos(); + let root = std::env::var_os("CARGO_TARGET_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(|| { + std::env::current_dir() + .expect("current directory should be available") + .join("target") + .join("test-tmp") + }); + let path = root.join(format!( + "multicode-apple-container-integration-{}-{}", + std::process::id(), + unique + )); + fs::create_dir_all(&path).expect("test root should be created"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +struct EnvVarGuard { + key: &'static str, + old_value: Option, +} + +impl EnvVarGuard { + fn set(key: &'static str, value: impl AsRef) -> Self { + let old_value = std::env::var_os(key); + unsafe { + std::env::set_var(key, value); + } + Self { key, old_value } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(value) = &self.old_value { + unsafe { + std::env::set_var(self.key, value); + } + } else { + unsafe { + std::env::remove_var(self.key); + } + } + } +} + +fn make_executable(path: &Path) { + let mut perms = fs::metadata(path) + .expect("executable metadata should exist") + .permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms).expect("permissions should be updated"); +} + +fn write_fake_container_cli(path: &Path) { + fs::write( + path, + r#"#!/bin/bash +set -euo pipefail +root="${MULTICODE_FAKE_CONTAINER_ROOT:?missing MULTICODE_FAKE_CONTAINER_ROOT}" +state_dir="$root/state" +mkdir -p "$state_dir" +printf '%s\n' "$*" >> "$root/commands.log" + +cmd="${1:-}" +shift || true +case "$cmd" in + run) + name="" + while [ "$#" -gt 0 ]; do + case "$1" in + --name) + name="$2" + shift 2 + ;; + *) + shift + ;; + esac + done + if [ -n "$name" ]; then + : > "$state_dir/$name" + fi + ;; + rm) + if [ "${1:-}" = "-f" ] && [ -n "${2:-}" ]; then + rm -f "$state_dir/$2" + fi + ;; + inspect) + if [ -n "${1:-}" ] && [ -e "$state_dir/$1" ]; then + printf '[{\"status\":\"running\"}]\n' + exit 0 + fi + exit 1 + ;; + list) + for file in "$state_dir"/*; do + [ -e "$file" ] || continue + basename "$file" + done + ;; + *) + ;; +esac +"#, + ) + .expect("fake container script should be written"); + make_executable(path); +} + +fn write_fake_opencode(path: &Path) { + fs::write(path, "#!/bin/bash\nexit 0\n").expect("fake opencode should be written"); + make_executable(path); +} + +fn read_commands(path: &Path) -> Vec { + fs::read_to_string(path) + .expect("commands log should be readable") + .lines() + .map(ToOwned::to_owned) + .collect() +} + +#[test] +fn starts_and_stops_workspace_with_apple_container_backend() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + let root = TestDir::new(); + let workspace_directory = root.path().join("workspaces"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let bin_dir = root.path().join("bin"); + let fake_container_root = root.path().join("fake-container"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&fake_container_root).expect("fake container root should exist"); + + write_fake_container_cli(&bin_dir.join("container")); + write_fake_opencode(&bin_dir.join("opencode")); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let test_path = format!("{}:{}", bin_dir.display(), old_path); + let _path_guard = EnvVarGuard::set("PATH", &test_path); + let _container_guard = + EnvVarGuard::set("MULTICODE_CONTAINER_COMMAND", bin_dir.join("container")); + let _port_guard = EnvVarGuard::set("MULTICODE_FIXED_PORT", "43123"); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + let _fake_root_guard = + EnvVarGuard::set("MULTICODE_FAKE_CONTAINER_ROOT", &fake_container_root); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + r#"workspace-directory = "{workspace_directory}" +opencode = ["opencode"] + +[runtime] +backend = "apple-container" +image = "ghcr.io/example/multicode-java25:latest" + +[isolation] +writable = ["{home}/.gradle", "{home}/.config/gh"] +isolated = ["{home}/.local/share/opencode", "{home}/.local/state/opencode", "/var/tmp"] +tmpfs = ["/tmp"] +inherit-env = ["HOME", "XDG_RUNTIME_DIR", "PATH"] +memory-max = "16 GiB" +cpu = "300%" +"#, + workspace_directory = workspace_directory.display(), + home = home.display(), + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace("alpha") + .await + .expect("workspace should be created"); + service + .start_workspace("alpha") + .await + .expect("workspace should start"); + + let snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + let transient = snapshot + .transient + .clone() + .expect("transient snapshot should be present"); + assert_eq!(transient.runtime.backend, RuntimeBackend::AppleContainer); + assert_eq!(transient.runtime.id, "multicode-alpha"); + assert!(transient.uri.starts_with("http://opencode:")); + + let commands = read_commands(&fake_container_root.join("commands.log")); + let run_command = commands + .iter() + .find(|line| line.starts_with("run ")) + .expect("run command should be logged"); + assert!(run_command.contains("--name multicode-alpha")); + assert!(run_command.contains("--cpus 3")); + assert!(run_command.contains("--memory 17179869184")); + assert!(run_command.contains("--tmpfs /tmp")); + assert!(run_command.contains("ghcr.io/example/multicode-java25:latest")); + assert!(run_command.contains("opencode serve --hostname 0.0.0.0")); + + let server_env = workspace_directory + .join(".multicode") + .join("apple-container") + .join("alpha") + .join("server.env"); + let env_contents = + fs::read_to_string(&server_env).expect("server env file should be written"); + assert!(env_contents.contains("OPENCODE_SERVER_USERNAME=opencode")); + assert!(env_contents.contains("OPENCODE_SERVER_PASSWORD=")); + assert!(env_contents.contains(&format!("HOME={}", home.display()))); + + service + .stop_workspace("alpha") + .await + .expect("workspace should stop"); + + let stopped = tokio::time::timeout(Duration::from_secs(2), async { + loop { + let snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + if snapshot.transient.is_none() { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await; + assert!(stopped.is_ok(), "workspace should clear transient state"); + + let commands = read_commands(&fake_container_root.join("commands.log")); + assert!( + commands.iter().any(|line| line == "rm -f multicode-alpha"), + "stop should remove the container" + ); + }); +} + +#[test] +fn start_workspace_removes_stale_named_container_before_run() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + let root = TestDir::new(); + let workspace_directory = root.path().join("workspaces"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let bin_dir = root.path().join("bin"); + let fake_container_root = root.path().join("fake-container"); + let fake_state_dir = fake_container_root.join("state"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&fake_state_dir).expect("fake container state dir should exist"); + + write_fake_container_cli(&bin_dir.join("container")); + write_fake_opencode(&bin_dir.join("opencode")); + fs::write(fake_state_dir.join("multicode-alpha"), "") + .expect("stale container should exist"); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let test_path = format!("{}:{}", bin_dir.display(), old_path); + let _path_guard = EnvVarGuard::set("PATH", &test_path); + let _container_guard = + EnvVarGuard::set("MULTICODE_CONTAINER_COMMAND", bin_dir.join("container")); + let _port_guard = EnvVarGuard::set("MULTICODE_FIXED_PORT", "43123"); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + let _fake_root_guard = + EnvVarGuard::set("MULTICODE_FAKE_CONTAINER_ROOT", &fake_container_root); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + r#"workspace-directory = "{workspace_directory}" +opencode = ["opencode"] + +[runtime] +backend = "apple-container" +image = "ghcr.io/example/multicode-java25:latest" + +[isolation] +inherit-env = ["HOME", "XDG_RUNTIME_DIR", "PATH"] +"#, + workspace_directory = workspace_directory.display(), + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace("alpha") + .await + .expect("workspace should be created"); + service + .start_workspace("alpha") + .await + .expect("workspace should start after removing stale container"); + + let commands = read_commands(&fake_container_root.join("commands.log")); + let stale_rm_index = commands + .iter() + .position(|line| line == "rm -f multicode-alpha") + .expect("stale container should be removed before start"); + let run_index = commands + .iter() + .position(|line| line.starts_with("run ")) + .expect("run command should be logged"); + assert!( + stale_rm_index < run_index, + "stale container removal should happen before run" + ); + }); +} + +#[test] +fn build_exec_tool_command_uses_one_shot_apple_container_run() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + let root = TestDir::new(); + let workspace_directory = root.path().join("workspaces"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let bin_dir = root.path().join("bin"); + fs::create_dir_all(workspace_directory.join("alpha")).expect("workspace root should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + write_fake_container_cli(&bin_dir.join("container")); + write_fake_opencode(&bin_dir.join("opencode")); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let test_path = format!("{}:{}", bin_dir.display(), old_path); + let _path_guard = EnvVarGuard::set("PATH", &test_path); + let _container_guard = + EnvVarGuard::set("MULTICODE_CONTAINER_COMMAND", bin_dir.join("container")); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + let _fake_root_guard = EnvVarGuard::set( + "MULTICODE_FAKE_CONTAINER_ROOT", + root.path().join("fake-root"), + ); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + r#"workspace-directory = "{workspace_directory}" +opencode = ["opencode"] + +[runtime] +backend = "apple-container" +image = "ghcr.io/example/multicode-java25:latest" + +[isolation] +inherit-env = ["HOME", "XDG_RUNTIME_DIR", "PATH"] +memory-max = "8 GiB" +cpu = "200%" +"#, + workspace_directory = workspace_directory.display(), + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + + let command = service + .build_exec_tool_command("alpha", "/bin/bash") + .await + .expect("exec tool command should build"); + assert_eq!(command.program, bin_dir.join("container").to_string_lossy()); + assert_eq!(command.inherited_env, Vec::<(String, String)>::new()); + assert!( + command.args.windows(4).any(|window| { + window + == ["run", "--rm", "--tty", "--interactive"] + .iter() + .map(|v| v.to_string()) + .collect::>() + }), + "apple backend should use one-shot container run for PTY tools" + ); + assert!(command.args.iter().any(|arg| arg == "--cpus")); + assert!(command.args.iter().any(|arg| arg == "2")); + assert!(command.args.iter().any(|arg| arg == "--memory")); + assert!(command.args.iter().any(|arg| arg == "8589934592")); + assert!(command.args.iter().any(|arg| arg.ends_with("exec.env"))); + assert!( + command + .args + .iter() + .any(|arg| arg == "ghcr.io/example/multicode-java25:latest") + ); + assert!(command.args.iter().any(|arg| arg == "/bin/bash")); + }); +} + +#[test] +fn stale_apple_container_transient_is_cleared_on_startup() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + let root = TestDir::new(); + let workspace_directory = root.path().join("workspaces"); + let workspace_path = workspace_directory.join("alpha"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let transient_dir = root.path().join("transient-store"); + let bin_dir = root.path().join("bin"); + let fake_container_root = root.path().join("fake-container"); + let fake_state_dir = fake_container_root.join("state"); + fs::create_dir_all(&workspace_path).expect("workspace should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + fs::create_dir_all(&transient_dir).expect("transient dir should exist"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + fs::create_dir_all(&fake_state_dir).expect("fake container state dir should exist"); + + write_fake_container_cli(&bin_dir.join("container")); + write_fake_opencode(&bin_dir.join("opencode")); + fs::write(fake_state_dir.join("multicode-alpha"), "") + .expect("stale container should exist"); + + let transient_link = workspace_directory.join(".multicode").join("transient"); + fs::create_dir_all( + transient_link + .parent() + .expect("transient link parent should be available"), + ) + .expect("transient link parent should exist"); + std::os::unix::fs::symlink(&transient_dir, &transient_link) + .expect("transient link should be created"); + fs::write( + transient_dir.join("alpha.json"), + serde_json::to_vec_pretty(&multicode_lib::TransientWorkspaceSnapshot { + uri: "http://opencode:secret@127.0.0.1:31337/".to_string(), + runtime: multicode_lib::RuntimeHandleSnapshot { + backend: RuntimeBackend::AppleContainer, + id: "multicode-alpha".to_string(), + metadata: std::collections::BTreeMap::new(), + }, + }) + .expect("transient snapshot should serialize"), + ) + .expect("transient snapshot should be written"); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let test_path = format!("{}:{}", bin_dir.display(), old_path); + let _path_guard = EnvVarGuard::set("PATH", &test_path); + let _container_guard = + EnvVarGuard::set("MULTICODE_CONTAINER_COMMAND", bin_dir.join("container")); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + let _fake_root_guard = + EnvVarGuard::set("MULTICODE_FAKE_CONTAINER_ROOT", &fake_container_root); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + r#"workspace-directory = "{workspace_directory}" +opencode = ["opencode"] + +[runtime] +backend = "apple-container" +image = "ghcr.io/example/multicode-java25:latest" + +[isolation] +readable = ["{home}/.config/opencode"] +inherit-env = ["HOME", "XDG_RUNTIME_DIR", "PATH"] +"#, + workspace_directory = workspace_directory.display(), + home = home.display(), + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + + let commands_log = fake_container_root.join("commands.log"); + let cleared = tokio::time::timeout(Duration::from_secs(2), async { + loop { + let snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + let removed_stale_container = fs::read_to_string(&commands_log) + .map(|content| content.lines().any(|line| line == "rm -f multicode-alpha")) + .unwrap_or(false); + if removed_stale_container && snapshot.transient.is_none() { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await; + assert!(cleared.is_ok(), "stale transient should be cleared"); + + let commands = read_commands(&commands_log); + assert!( + commands.iter().any(|line| line == "rm -f multicode-alpha"), + "stale apple container should be removed during reconciliation" + ); + }); +} + +#[test] +#[ignore = "requires a real Apple container image with opencode installed"] +fn real_apple_container_backend_starts_and_stops_with_supplied_image() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let _env_lock = ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + let image = std::env::var("MULTICODE_APPLE_CONTAINER_TEST_IMAGE").expect( + "set MULTICODE_APPLE_CONTAINER_TEST_IMAGE to a real image that contains opencode", + ); + + let root = TestDir::new(); + let workspace_directory = root.path().join("workspaces"); + let home = root.path().join("home"); + let runtime_dir = root.path().join("runtime"); + let bin_dir = root.path().join("bin"); + fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::create_dir_all(&runtime_dir).expect("runtime dir should exist"); + fs::create_dir_all(&bin_dir).expect("bin dir should exist"); + write_fake_opencode(&bin_dir.join("opencode")); + + let old_path = std::env::var("PATH").unwrap_or_default(); + let test_path = format!("{}:{}", bin_dir.display(), old_path); + let _path_guard = EnvVarGuard::set("PATH", &test_path); + let _home_guard = EnvVarGuard::set("HOME", &home); + let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); + + let config_path = root.path().join("config.toml"); + fs::write( + &config_path, + format!( + r#"workspace-directory = "{workspace_directory}" +opencode = ["opencode"] + +[runtime] +backend = "apple-container" +image = "{image}" + +[isolation] +inherit-env = ["HOME", "XDG_RUNTIME_DIR", "PATH"] +memory-max = "4 GiB" +cpu = "100%" +"#, + workspace_directory = workspace_directory.display(), + image = image, + ), + ) + .expect("config should be written"); + + let service = CombinedService::from_config_path(&config_path) + .await + .expect("combined service should start"); + service + .create_workspace("alpha") + .await + .expect("workspace should be created"); + service + .start_workspace("alpha") + .await + .expect("workspace should start with real container backend"); + + let snapshot = service + .manager + .get_workspace("alpha") + .expect("workspace should exist") + .subscribe() + .borrow() + .clone(); + let transient = snapshot + .transient + .clone() + .expect("transient snapshot should be present"); + assert_eq!(transient.runtime.backend, RuntimeBackend::AppleContainer); + + service + .stop_workspace("alpha") + .await + .expect("workspace should stop"); + }); +} From af4b74248c345f087afe5420bae6a119fea5fe5f Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Thu, 9 Apr 2026 10:41:14 +0200 Subject: [PATCH 3/4] ensure git config is mounted in Apple containers --- README.md | 5 +- config.toml | 1 + lib/src/services/combined.rs | 177 +++++++++++------------------------ lib/src/services/runtime.rs | 70 +++++++++++++- 4 files changed, 129 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index 064b936..5cde608 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ image = "ghcr.io/example/multicode-java25:latest" [isolation] writable = ["~/.gradle", "~/.m2/repository", "~/.config/gh"] -readable = ["~/.config/opencode", "~/.local/share/opencode/auth.json"] +readable = ["~/.gitconfig", "~/.config/opencode", "~/.local/share/opencode/auth.json"] isolated = ["~/.local/share/opencode", "~/.local/state/opencode"] tmpfs = ["/tmp"] inherit-env = ["HOME", "PATH", "XDG_RUNTIME_DIR", "GITHUB_MCP_TOKEN"] @@ -61,6 +61,9 @@ skills, and other OpenCode configuration as the host. This is useful if you mana profiles with tools like `ocp`. Keep `~/.local/share/opencode` and `~/.local/state/opencode` isolated so session state remains per-workspace. +Mounting `~/.gitconfig` read-only lets the container see your global git identity and defaults. +Repo-local `.git/config` settings still override the global file. + ## Git / GitHub integration With the GitHub integration you can see progress at a glance in the overview screen, and navigate to the issue or PR diff --git a/config.toml b/config.toml index b51cd91..7f60c77 100644 --- a/config.toml +++ b/config.toml @@ -35,6 +35,7 @@ isolated = [ "~/.local/state/opencode", ] readable = [ + "~/.gitconfig", "~/.config/opencode", "~/.local/share/opencode/auth.json", ] diff --git a/lib/src/services/combined.rs b/lib/src/services/combined.rs index a0815a5..3f88af4 100644 --- a/lib/src/services/combined.rs +++ b/lib/src/services/combined.rs @@ -466,27 +466,7 @@ impl CombinedService { } fn github_git_credentials_env_vars(&self) -> Vec<(String, String)> { - let Some(github_git_credentials_env) = &self.github_git_credentials_env else { - return Vec::new(); - }; - - let helper = r#"!f() { test "$1" = get || exit 0; echo username=$MULTICODE_GITHUB_USERNAME; echo password=$MULTICODE_GITHUB_TOKEN; }; f"#; - vec![ - ( - "MULTICODE_GITHUB_USERNAME".to_string(), - github_git_credentials_env.username.clone(), - ), - ( - "MULTICODE_GITHUB_TOKEN".to_string(), - github_git_credentials_env.token.clone(), - ), - ("GIT_CONFIG_COUNT".to_string(), "1".to_string()), - ( - "GIT_CONFIG_KEY_0".to_string(), - "credential.helper".to_string(), - ), - ("GIT_CONFIG_VALUE_0".to_string(), helper.to_string()), - ] + github_git_credentials_env_vars(self.github_git_credentials_env.as_ref()) } async fn compress_directory_to_archive( @@ -675,6 +655,40 @@ fn resolve_container_opencode_command( .unwrap_or_else(|| "opencode".to_string()) } +fn github_git_credentials_env_vars( + github_git_credentials_env: Option<&GithubGitCredentialsEnv>, +) -> Vec<(String, String)> { + let Some(github_git_credentials_env) = github_git_credentials_env else { + return Vec::new(); + }; + + let helper = r#"!f() { test "$1" = get || exit 0; echo username=$MULTICODE_GITHUB_USERNAME; echo password=$MULTICODE_GITHUB_TOKEN; }; f"#; + vec![ + ( + "MULTICODE_GITHUB_USERNAME".to_string(), + github_git_credentials_env.username.clone(), + ), + ( + "MULTICODE_GITHUB_TOKEN".to_string(), + github_git_credentials_env.token.clone(), + ), + ( + "GH_TOKEN".to_string(), + github_git_credentials_env.token.clone(), + ), + ( + "GITHUB_TOKEN".to_string(), + github_git_credentials_env.token.clone(), + ), + ("GIT_CONFIG_COUNT".to_string(), "1".to_string()), + ( + "GIT_CONFIG_KEY_0".to_string(), + "credential.helper".to_string(), + ), + ("GIT_CONFIG_VALUE_0".to_string(), helper.to_string()), + ] +} + async fn github_git_credentials_env_from_config( config: &Config, github_status_service: &GithubStatusService, @@ -1525,110 +1539,29 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] #[test] fn github_git_credentials_env_vars_include_helper_and_secrets() { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("tokio runtime should build"); - - runtime.block_on(async { - let _env_lock = ENV_VAR_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let root = TestDir::new(); - let home = root.path().join("home"); - let runtime_dir = root.path().join("runtime"); - let github_api_dir = root.path().join("github-api"); - let github_server = github_api_dir.join("server.py"); - let github_port = 38492; - fs::create_dir_all(&home).expect("home should exist"); - fs::create_dir_all(&runtime_dir).expect("runtime should exist"); - fs::create_dir_all(&github_api_dir).expect("github api dir should exist"); - let workspace_directory = home.join("workspaces"); - fs::create_dir_all(&workspace_directory).expect("workspace root should exist"); - - fs::write( - &github_server, - format!( - r#"from http.server import BaseHTTPRequestHandler, HTTPServer -class Handler(BaseHTTPRequestHandler): - def do_GET(self): - if self.path == "/user": - body = b'{{"login":"sandbox-user","id":1,"node_id":"MDQ6VXNlcjE=","avatar_url":"https://example.com/avatar","gravatar_id":"","url":"https://api.github.com/users/sandbox-user","html_url":"https://github.com/sandbox-user","followers_url":"https://api.github.com/users/sandbox-user/followers","following_url":"https://api.github.com/users/sandbox-user/following{{/other_user}}","gists_url":"https://api.github.com/users/sandbox-user/gists{{/gist_id}}","starred_url":"https://api.github.com/users/sandbox-user/starred{{/owner}}{{/repo}}","subscriptions_url":"https://api.github.com/users/sandbox-user/subscriptions","organizations_url":"https://api.github.com/users/sandbox-user/orgs","repos_url":"https://api.github.com/users/sandbox-user/repos","events_url":"https://api.github.com/users/sandbox-user/events{{/privacy}}","received_events_url":"https://api.github.com/users/sandbox-user/received_events","type":"User","site_admin":false,"name":"Sandbox User","company":null,"blog":"","location":null,"email":null,"hireable":null,"bio":null,"twitter_username":null,"public_repos":0,"public_gists":0,"followers":0,"following":0,"created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z","private_gists":0,"total_private_repos":0,"owned_private_repos":0,"disk_usage":0,"collaborators":0,"two_factor_authentication":false}}' - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - else: - self.send_response(404) - self.end_headers() - def log_message(self, format, *args): - pass -HTTPServer(("127.0.0.1", {github_port}), Handler).serve_forever() -"# - ), - ) - .expect("github server script should be written"); - let mut github_process = std::process::Command::new("python3") - .arg(&github_server) - .spawn() - .expect("github api server should start"); - std::thread::sleep(std::time::Duration::from_millis(250)); - - let _home_guard = EnvVarGuard::set("HOME", &home); - let _xdg_guard = EnvVarGuard::set("XDG_RUNTIME_DIR", &runtime_dir); - unsafe { - std::env::set_var("MULTICODE_GITHUB_TEST_TOKEN", "secret-token"); - std::env::set_var("GITHUB_API_URL", format!("http://127.0.0.1:{github_port}")); - } - - let config: Config = toml::from_str( - &format!( - r#"workspace-directory = "{}" - -[github] -populate-git-credentials = true -token = {{ env = "MULTICODE_GITHUB_TEST_TOKEN" }} - -[isolation] -"#, - workspace_directory.display() - ), - ) - .expect("config should parse"); - - let service = CombinedService::from_config(config) - .await - .expect("combined service should start"); - let env_vars = service.github_git_credentials_env_vars(); - assert!(env_vars.contains(&( - "MULTICODE_GITHUB_USERNAME".to_string(), - "sandbox-user".to_string(), - ))); - assert!(env_vars.contains(&( - "MULTICODE_GITHUB_TOKEN".to_string(), - "secret-token".to_string(), - ))); - assert!(env_vars.contains(&( - "GIT_CONFIG_COUNT".to_string(), - "1".to_string(), - ))); - assert!(env_vars.contains(&( - "GIT_CONFIG_KEY_0".to_string(), - "credential.helper".to_string(), - ))); - assert!(env_vars.contains(&( + let env_vars = github_git_credentials_env_vars(Some(&GithubGitCredentialsEnv { + username: "sandbox-user".to_string(), + token: "secret-token".to_string(), + })); + assert!(env_vars.contains(&( + "MULTICODE_GITHUB_USERNAME".to_string(), + "sandbox-user".to_string(), + ))); + assert!(env_vars.contains(&( + "MULTICODE_GITHUB_TOKEN".to_string(), + "secret-token".to_string(), + ))); + assert!(env_vars.contains(&("GH_TOKEN".to_string(), "secret-token".to_string(),))); + assert!(env_vars.contains(&("GITHUB_TOKEN".to_string(), "secret-token".to_string(),))); + assert!(env_vars.contains(&("GIT_CONFIG_COUNT".to_string(), "1".to_string(),))); + assert!(env_vars.contains(&( + "GIT_CONFIG_KEY_0".to_string(), + "credential.helper".to_string(), + ))); + assert!(env_vars.contains(&( "GIT_CONFIG_VALUE_0".to_string(), r#"!f() { test "$1" = get || exit 0; echo username=$MULTICODE_GITHUB_USERNAME; echo password=$MULTICODE_GITHUB_TOKEN; }; f"#.to_string(), ))); - - unsafe { - std::env::remove_var("MULTICODE_GITHUB_TEST_TOKEN"); - std::env::remove_var("GITHUB_API_URL"); - } - let _ = github_process.kill(); - let _ = github_process.wait(); - }); } #[test] diff --git a/lib/src/services/runtime.rs b/lib/src/services/runtime.rs index cb1697a..6ad0c45 100644 --- a/lib/src/services/runtime.rs +++ b/lib/src/services/runtime.rs @@ -9,7 +9,7 @@ use uuid::Uuid; use super::{ combined::{CombinedServiceError, SpawnCommand}, - config::{ExpandedIsolationConfig, RuntimeConfig, path_looks_like_file}, + config::{ExpandedIsolationConfig, RuntimeConfig, expand_shell_path, path_looks_like_file}, }; use crate::{RuntimeBackend, RuntimeHandleSnapshot, TransientWorkspaceSnapshot}; @@ -897,6 +897,7 @@ impl AppleContainerRuntime { }), ); } + mount_specs.extend(self.implicit_readable_mounts(&mount_specs)); mount_specs.sort_by(|a, b| { a.depth() .cmp(&b.depth()) @@ -929,6 +930,24 @@ impl AppleContainerRuntime { Ok(()) } + fn implicit_readable_mounts(&self, existing_mounts: &[MountSpec]) -> Vec { + let Some(gitconfig) = expand_shell_path("~/.gitconfig") + .ok() + .filter(|path| path.is_absolute() && path.is_file()) + else { + return Vec::new(); + }; + + if existing_mounts + .iter() + .any(|mount| mount.target == gitconfig) + { + return Vec::new(); + } + + vec![MountSpec::new(gitconfig, None, MountKind::Readable)] + } + async fn build_aggregated_skill_mount( &self, key: &str, @@ -1579,6 +1598,55 @@ mod tests { }); } + #[test] + fn apple_container_implicitly_mounts_host_gitconfig() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace_root = root.path().join("workspaces"); + let home = root.path().join("home"); + let gitconfig = home.join(".gitconfig"); + fs::create_dir_all(&workspace_root).expect("workspace root should exist"); + fs::create_dir_all(&home).expect("home should exist"); + fs::write(&gitconfig, "[user]\nname = Test User\n").expect("gitconfig should exist"); + + let previous_home = std::env::var_os("HOME"); + unsafe { + std::env::set_var("HOME", &home); + } + + let runtime = apple_runtime(&root, IsolationConfig::default()); + let command = runtime + .build_run_command("alpha", "multicode-alpha", "secret", 31337, &[]) + .await + .expect("command should build"); + + if let Some(previous_home) = previous_home { + unsafe { + std::env::set_var("HOME", previous_home); + } + } else { + unsafe { + std::env::remove_var("HOME"); + } + } + + let gitconfig_mount = format!( + "type=bind,source={},target={},readonly", + gitconfig.to_string_lossy(), + gitconfig.to_string_lossy() + ); + assert!( + command.args.iter().any(|arg| arg == &gitconfig_mount), + "apple backend should implicitly mount ~/.gitconfig read-only" + ); + }); + } + #[test] fn apple_container_pty_command_uses_one_shot_container_run() { let runtime = tokio::runtime::Builder::new_current_thread() From e7a41dae739e9dcc6eb3772d4807042dd5fbcb28 Mon Sep 17 00:00:00 2001 From: Graeme Rocher Date: Thu, 9 Apr 2026 10:51:13 +0200 Subject: [PATCH 4/4] fix git config for apple containers --- README.md | 7 +- config.toml | 1 - lib/src/services/combined.rs | 154 +++++++++++++++++++++++++++++++++++ lib/src/services/runtime.rs | 133 ++++++++++++++++++++++++------ 4 files changed, 268 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 5cde608..51f33a8 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ image = "ghcr.io/example/multicode-java25:latest" [isolation] writable = ["~/.gradle", "~/.m2/repository", "~/.config/gh"] -readable = ["~/.gitconfig", "~/.config/opencode", "~/.local/share/opencode/auth.json"] +readable = ["~/.config/opencode", "~/.local/share/opencode/auth.json"] isolated = ["~/.local/share/opencode", "~/.local/state/opencode"] tmpfs = ["/tmp"] inherit-env = ["HOME", "PATH", "XDG_RUNTIME_DIR", "GITHUB_MCP_TOKEN"] @@ -61,8 +61,9 @@ skills, and other OpenCode configuration as the host. This is useful if you mana profiles with tools like `ocp`. Keep `~/.local/share/opencode` and `~/.local/state/opencode` isolated so session state remains per-workspace. -Mounting `~/.gitconfig` read-only lets the container see your global git identity and defaults. -Repo-local `.git/config` settings still override the global file. +Apple workspaces also expose the host `~/.gitconfig` automatically. The runtime mounts it through +an internal read-only path and sets `GIT_CONFIG_GLOBAL` so git can use your host global identity +and defaults without requiring a direct file bind. ## Git / GitHub integration diff --git a/config.toml b/config.toml index 7f60c77..b51cd91 100644 --- a/config.toml +++ b/config.toml @@ -35,7 +35,6 @@ isolated = [ "~/.local/state/opencode", ] readable = [ - "~/.gitconfig", "~/.config/opencode", "~/.local/share/opencode/auth.json", ] diff --git a/lib/src/services/combined.rs b/lib/src/services/combined.rs index 3f88af4..0d27463 100644 --- a/lib/src/services/combined.rs +++ b/lib/src/services/combined.rs @@ -180,6 +180,7 @@ impl CombinedService { let workspace = self.manager.get_workspace(&key)?; let workspace_path = self.workspace_directory_path.join(&key); tokio::fs::create_dir_all(&workspace_path).await?; + strip_workspace_git_identity_overrides(&workspace_path).await?; let inherited_env = self .sandbox_env_pairs(Vec::<(String, String)>::new()) @@ -689,6 +690,74 @@ fn github_git_credentials_env_vars( ] } +async fn strip_workspace_git_identity_overrides( + workspace_path: &Path, +) -> Result<(), CombinedServiceError> { + let workspace_path = workspace_path.to_path_buf(); + let repo_roots = tokio::task::spawn_blocking(move || find_git_repo_roots(&workspace_path)) + .await + .map_err(|err| std::io::Error::other(err.to_string()))??; + + for repo_root in repo_roots { + unset_repo_local_git_config(&repo_root, "user.name").await?; + unset_repo_local_git_config(&repo_root, "user.email").await?; + } + + Ok(()) +} + +fn find_git_repo_roots(workspace_path: &Path) -> Result, std::io::Error> { + let mut stack = vec![workspace_path.to_path_buf()]; + let mut repo_roots = std::collections::BTreeSet::new(); + + while let Some(directory) = stack.pop() { + let entries = match std::fs::read_dir(&directory) { + Ok(entries) => entries, + Err(err) if err.kind() == ErrorKind::NotFound => continue, + Err(err) => return Err(err), + }; + + for entry in entries { + let entry = entry?; + let path = entry.path(); + let file_type = entry.file_type()?; + if entry.file_name() == ".git" { + repo_roots.insert(directory.clone()); + continue; + } + if file_type.is_dir() { + stack.push(path); + } + } + } + + Ok(repo_roots.into_iter().collect()) +} + +async fn unset_repo_local_git_config( + repo_root: &Path, + key: &str, +) -> Result<(), CombinedServiceError> { + let output = Command::new("git") + .arg("-C") + .arg(repo_root) + .args(["config", "--local", "--unset-all", key]) + .stdin(Stdio::null()) + .output() + .await?; + + if output.status.success() || output.status.code() == Some(5) { + return Ok(()); + } + + Err(std::io::Error::other(format!( + "failed to remove repo-local git config {key} from {}: {}", + repo_root.display(), + String::from_utf8_lossy(&output.stderr).trim() + )) + .into()) +} + async fn github_git_credentials_env_from_config( config: &Config, github_status_service: &GithubStatusService, @@ -1564,6 +1633,91 @@ inherit-env = ["HOME", "XDG_RUNTIME_DIR"] ))); } + #[test] + fn strip_workspace_git_identity_overrides_removes_repo_local_user_identity() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("tokio runtime should build"); + + runtime.block_on(async { + let root = TestDir::new(); + let workspace = root.path().join("workspace"); + let repo = workspace.join("repo"); + fs::create_dir_all(&repo).expect("repo dir should exist"); + + let init = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["init"]) + .stdin(Stdio::null()) + .output() + .await + .expect("git init should run"); + assert!(init.status.success(), "git init should succeed"); + + let set_name = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["config", "--local", "user.name", "Local Name"]) + .stdin(Stdio::null()) + .output() + .await + .expect("git config user.name should run"); + assert!( + set_name.status.success(), + "git config user.name should succeed" + ); + + let set_email = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["config", "--local", "user.email", "local@example.com"]) + .stdin(Stdio::null()) + .output() + .await + .expect("git config user.email should run"); + assert!( + set_email.status.success(), + "git config user.email should succeed" + ); + + strip_workspace_git_identity_overrides(&workspace) + .await + .expect("workspace git identity cleanup should succeed"); + + let get_name = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["config", "--local", "--get", "user.name"]) + .stdin(Stdio::null()) + .output() + .await + .expect("git config get user.name should run"); + assert_eq!(get_name.status.code(), Some(1)); + + let get_email = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["config", "--local", "--get", "user.email"]) + .stdin(Stdio::null()) + .output() + .await + .expect("git config get user.email should run"); + assert_eq!(get_email.status.code(), Some(1)); + + let remote = Command::new("git") + .arg("-C") + .arg(&repo) + .args(["config", "--local", "core.repositoryformatversion"]) + .stdin(Stdio::null()) + .output() + .await + .expect("git config core.repositoryformatversion should run"); + assert!(remote.status.success(), "repo config should remain intact"); + }); + } + #[test] fn start_workspace_builds_expected_isolation_command_arguments() { let runtime = tokio::runtime::Builder::new_current_thread() diff --git a/lib/src/services/runtime.rs b/lib/src/services/runtime.rs index 6ad0c45..c98d4c3 100644 --- a/lib/src/services/runtime.rs +++ b/lib/src/services/runtime.rs @@ -9,11 +9,13 @@ use uuid::Uuid; use super::{ combined::{CombinedServiceError, SpawnCommand}, - config::{ExpandedIsolationConfig, RuntimeConfig, expand_shell_path, path_looks_like_file}, + config::{ExpandedIsolationConfig, RuntimeConfig, path_looks_like_file}, }; use crate::{RuntimeBackend, RuntimeHandleSnapshot, TransientWorkspaceSnapshot}; pub(super) const RUNTIME_SPEC_METADATA_KEY: &str = "runtime-spec"; +const APPLE_GITCONFIG_DIR: &str = "/multicode-host/git"; +const APPLE_GITCONFIG_FILE_NAME: &str = ".gitconfig"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum RuntimeActivity { @@ -685,7 +687,11 @@ impl AppleContainerRuntime { message: "apple-container backend requires a runtime image".to_string(), } })?; - let env_file = self.write_env_file(key, "exec.env", inherited_env).await?; + let mut env = inherited_env.to_vec(); + let host_gitconfig = self.host_gitconfig_path_for_env(&env); + self.append_implicit_env(&mut env, host_gitconfig.as_deref()) + .await?; + let env_file = self.write_env_file(key, "exec.env", &env).await?; let workspace_path = self.context.workspace_directory_path.join(key); let mut args = vec![ "run".to_string(), @@ -698,7 +704,8 @@ impl AppleContainerRuntime { workspace_path.to_string_lossy().into_owned(), ]; self.append_container_limits(&mut args); - self.append_container_mounts(args.as_mut(), key).await?; + self.append_container_mounts(args.as_mut(), key, host_gitconfig.as_deref()) + .await?; args.push(image.to_string()); args.extend(command); @@ -778,6 +785,9 @@ impl AppleContainerRuntime { "opencode".to_string(), )); env.push(("OPENCODE_SERVER_PASSWORD".to_string(), password.to_string())); + let host_gitconfig = self.host_gitconfig_path_for_env(&env); + self.append_implicit_env(&mut env, host_gitconfig.as_deref()) + .await?; let workspace_path = self.context.workspace_directory_path.join(key); tokio::fs::create_dir_all(&workspace_path).await?; @@ -797,7 +807,8 @@ impl AppleContainerRuntime { format!("127.0.0.1:{port}:{port}/tcp"), ]; self.append_container_limits(&mut args); - self.append_container_mounts(&mut args, key).await?; + self.append_container_mounts(&mut args, key, host_gitconfig.as_deref()) + .await?; args.push(image.to_string()); args.push(self.context.container_opencode_command.clone()); args.push("serve".to_string()); @@ -840,8 +851,12 @@ impl AppleContainerRuntime { &self, args: &mut Vec, key: &str, + host_gitconfig: Option<&Path>, ) -> Result<(), CombinedServiceError> { let workspace_path = self.context.workspace_directory_path.join(key); + let implicit_gitconfig_mount = self + .build_implicit_gitconfig_mount(key, host_gitconfig) + .await?; let mut mount_specs = Vec::new(); mount_specs.extend( self.context @@ -849,6 +864,7 @@ impl AppleContainerRuntime { .readable .iter() .cloned() + .filter(|path| !self.is_implicitly_handled_gitconfig(path, host_gitconfig)) .map(|path| MountSpec::new(path, None, MountKind::Readable)), ); mount_specs.extend( @@ -897,7 +913,9 @@ impl AppleContainerRuntime { }), ); } - mount_specs.extend(self.implicit_readable_mounts(&mount_specs)); + if let Some(implicit_gitconfig_mount) = implicit_gitconfig_mount { + mount_specs.push(implicit_gitconfig_mount); + } mount_specs.sort_by(|a, b| { a.depth() .cmp(&b.depth()) @@ -918,8 +936,10 @@ impl AppleContainerRuntime { .as_ref() .is_some_and(|source| source != &resolved_mount.effective_source)); resolved_mount.prepare_source_node(owns_source_node).await?; - resolved_mount.prepare_target_node(owns_node).await?; - resolved_mount.prepare_container_materialized_file().await?; + if resolved_mount.needs_container_target_materialization() { + resolved_mount.prepare_target_node(owns_node).await?; + resolved_mount.prepare_container_materialized_file().await?; + } resolved_mounts.push(resolved_mount); } @@ -930,22 +950,61 @@ impl AppleContainerRuntime { Ok(()) } - fn implicit_readable_mounts(&self, existing_mounts: &[MountSpec]) -> Vec { - let Some(gitconfig) = expand_shell_path("~/.gitconfig") - .ok() - .filter(|path| path.is_absolute() && path.is_file()) - else { - return Vec::new(); + async fn append_implicit_env( + &self, + env: &mut Vec<(String, String)>, + host_gitconfig: Option<&Path>, + ) -> Result<(), CombinedServiceError> { + if host_gitconfig.is_some() { + env.push(( + "GIT_CONFIG_GLOBAL".to_string(), + format!("{APPLE_GITCONFIG_DIR}/{APPLE_GITCONFIG_FILE_NAME}"), + )); + } + Ok(()) + } + + async fn build_implicit_gitconfig_mount( + &self, + key: &str, + host_gitconfig: Option<&Path>, + ) -> Result, CombinedServiceError> { + let Some(host_gitconfig) = host_gitconfig else { + return Ok(None); }; - if existing_mounts - .iter() - .any(|mount| mount.target == gitconfig) - { - return Vec::new(); + let source_root = self.apple_runtime_root(key).join("gitconfig"); + match tokio::fs::remove_dir_all(&source_root).await { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), } + tokio::fs::create_dir_all(&source_root).await?; + let gitconfig_contents = std::fs::read(host_gitconfig)?; + tokio::fs::write( + source_root.join(APPLE_GITCONFIG_FILE_NAME), + gitconfig_contents, + ) + .await?; + + Ok(Some(MountSpec::new( + PathBuf::from(APPLE_GITCONFIG_DIR), + Some(source_root), + MountKind::Readable, + ))) + } + + fn host_gitconfig_path_for_env(&self, env: &[(String, String)]) -> Option { + let home = env + .iter() + .find(|(name, _)| name == "HOME") + .map(|(_, value)| value)?; + let path = PathBuf::from(home).join(".gitconfig"); + (path.is_absolute() && path.is_file() && std::fs::read(&path).is_ok()).then_some(path) + } - vec![MountSpec::new(gitconfig, None, MountKind::Readable)] + fn is_implicitly_handled_gitconfig(&self, path: &Path, host_gitconfig: Option<&Path>) -> bool { + host_gitconfig.is_some_and(|gitconfig| gitconfig == path) } async fn build_aggregated_skill_mount( @@ -1303,6 +1362,10 @@ pub(crate) struct ResolvedMountSpec { } impl ResolvedMountSpec { + fn needs_container_target_materialization(&self) -> bool { + self.backing_mount_kind.is_some() && self.effective_target != self.mount.target + } + pub(crate) async fn prepare_source_node( &self, owns_node: bool, @@ -1621,9 +1684,20 @@ mod tests { let runtime = apple_runtime(&root, IsolationConfig::default()); let command = runtime - .build_run_command("alpha", "multicode-alpha", "secret", 31337, &[]) + .build_run_command( + "alpha", + "multicode-alpha", + "secret", + 31337, + &[("HOME".to_string(), home.to_string_lossy().into_owned())], + ) .await .expect("command should build"); + let server_env = workspace_root + .join(".multicode") + .join("apple-container") + .join("alpha") + .join("server.env"); if let Some(previous_home) = previous_home { unsafe { @@ -1637,12 +1711,25 @@ mod tests { let gitconfig_mount = format!( "type=bind,source={},target={},readonly", - gitconfig.to_string_lossy(), - gitconfig.to_string_lossy() + workspace_root + .join(".multicode") + .join("apple-container") + .join("alpha") + .join("gitconfig") + .to_string_lossy(), + APPLE_GITCONFIG_DIR ); assert!( command.args.iter().any(|arg| arg == &gitconfig_mount), - "apple backend should implicitly mount ~/.gitconfig read-only" + "apple backend should implicitly mount host gitconfig through a synthetic directory" + ); + let env_contents = + fs::read_to_string(&server_env).expect("server env file should be written"); + assert!( + env_contents.contains(&format!( + "GIT_CONFIG_GLOBAL={APPLE_GITCONFIG_DIR}/{APPLE_GITCONFIG_FILE_NAME}" + )), + "apple backend should point git at the synthetic mounted gitconfig" ); }); }