@@ -19,8 +19,8 @@ use crate::{
1919 config::CONFIG_PATH_ENV,
2020 instance::SERIAL_LOG_NAME,
2121 instance::{
22− InstanceConfig, build_ssh_login_actions, ensure_instance_dir, ensure_ssh_keypair,
23− extract_ipv4, load_or_create_instance_config, write_instance_config,
22+ DEFAULT_SSH_USER, InstanceConfig, build_ssh_login_actions, ensure_instance_dir,
23+ ensure_ssh_keypair, extract_ipv4, load_or_create_instance_config, write_instance_config,
2424 },
2525 session_manager::{
2626 GLOBAL_DIR_NAME, INSTANCE_FILENAME, VM_MANAGER_PID_NAME, VM_MANAGER_SOCKET_NAME,
@@ -207,6 +207,113 @@ fn is_socket_path(path: &Path) -> bool {
207207 .unwrap_or(false)
208208}
209209
210+fn prepare_mounts_and_links(mut args: vm::VmArg, ssh_user: &str) -> (vm::VmArg, String) {
211+ let mut links = Vec::new();
212+ let mut mounts = Vec::with_capacity(args.mounts.len());
213+ for spec in args.mounts {
214+ let (rewritten, link) = rewrite_mount_spec(&spec, ssh_user);
215+ if let Some(link) = link {
216+ links.push(link);
217+ }
218+ mounts.push(rewritten);
219+ }
220+ args.mounts = mounts;
221+ let script = render_home_links_script(&links, ssh_user);
222+ (args, script)
223+}
224+
225+struct HomeLink {
226+ source: String,
227+ target: String,
228+}
229+
230+fn rewrite_mount_spec(spec: &str, ssh_user: &str) -> (String, Option<HomeLink>) {
231+ let parts: Vec<&str> = spec.split(':').collect();
232+ if parts.len() < 2 || parts.len() > 3 {
233+ return (spec.to_string(), None);
234+ }
235+ let host = parts[0];
236+ let guest = parts[1];
237+ let mode = parts.get(2).copied();
238+
239+ let home_prefix = format!("/home/{ssh_user}");
240+ let (rel, is_home) = if guest == "~" {
241+ (String::new(), true)
242+ } else if let Some(stripped) = guest.strip_prefix("~/") {
243+ (stripped.to_string(), true)
244+ } else if guest == home_prefix {
245+ (String::new(), true)
246+ } else if let Some(stripped) = guest.strip_prefix(&(home_prefix.clone() + "/")) {
247+ (stripped.to_string(), true)
248+ } else {
249+ (String::new(), false)
250+ };
251+
252+ if !is_home {
253+ return (spec.to_string(), None);
254+ }
255+
256+ let root_base = "/usr/local/vibebox-mounts";
257+ let root_path = if rel.is_empty() {
258+ root_base.to_string()
259+ } else {
260+ format!("{root_base}/{rel}")
261+ };
262+ let target = if rel.is_empty() {
263+ home_prefix
264+ } else {
265+ format!("{home_prefix}/{rel}")
266+ };
267+
268+ let rewritten = match mode {
269+ Some(mode) => format!("{host}:{root_path}:{mode}"),
270+ None => format!("{host}:{root_path}"),
271+ };
272+
273+ (
274+ rewritten,
275+ Some(HomeLink {
276+ source: root_path,
277+ target,
278+ }),
279+ )
280+}
281+
282+fn render_home_links_script(links: &[HomeLink], ssh_user: &str) -> String {
283+ if links.is_empty() {
284+ return String::new();
285+ }
286+ let mut lines = Vec::new();
287+ lines.push("link_home() {".to_string());
288+ lines.push(" src=\"$1\"".to_string());
289+ lines.push(" dest=\"$2\"".to_string());
290+ lines.push(" if [ -L \"$dest\" ]; then".to_string());
291+ lines.push(" current=\"$(readlink \"$dest\" || true)\"".to_string());
292+ lines.push(" if [ \"$current\" != \"$src\" ]; then".to_string());
293+ lines.push(" rm -f \"$dest\"".to_string());
294+ lines.push(" fi".to_string());
295+ lines.push(" fi".to_string());
296+ lines.push(" if [ ! -e \"$dest\" ]; then".to_string());
297+ lines.push(" mkdir -p \"$(dirname \"$dest\")\"".to_string());
298+ lines.push(" ln -s \"$src\" \"$dest\"".to_string());
299+ lines.push(" fi".to_string());
300+ lines.push(format!(
301+ " chown -h \"{ssh_user}:{ssh_user}\" \"$dest\" 2>/dev/null || true"
302+ ));
303+ lines.push("}".to_string());
304+ for link in links {
305+ let src = shell_escape(&link.source);
306+ let dest = shell_escape(&link.target);
307+ lines.push(format!("link_home {src} {dest}"));
308+ }
309+ lines.join("\n")
310+}
311+
312+fn shell_escape(value: &str) -> String {
313+ let escaped = value.replace('\'', "'\"'\"'");
314+ format!("'{escaped}'")
315+}
316+
210317struct PidFileGuard {
211318 path: PathBuf,
212319}
@@ -532,6 +639,11 @@ fn run_manager_with(
532639 write_instance_config(&instance_dir.join(INSTANCE_FILENAME), &config)?;
533640 }
534641 let config = Arc::new(Mutex::new(config));
642+ let ssh_user = config
643+ .lock()
644+ .map(|cfg| cfg.ssh_user_display())
645+ .unwrap_or_else(|_| DEFAULT_SSH_USER.to_string());
646+ let (args, home_links_script) = prepare_mounts_and_links(args, &ssh_user);
535647
536648 let ssh_guest_dir = format!("/root/{}", GLOBAL_DIR_NAME);
537649 let extra_shares = vec![DirectoryShare::new(
@@ -539,8 +651,13 @@ fn run_manager_with(
539651 ssh_guest_dir.clone().into(),
540652 true,
541653 )?];
542− let extra_login_actions =
543− build_ssh_login_actions(&config, &project_name, ssh_guest_dir.as_str(), "ssh_key");
654+ let extra_login_actions = build_ssh_login_actions(
655+ &config,
656+ &project_name,
657+ ssh_guest_dir.as_str(),
658+ "ssh_key",
659+ &home_links_script,
660+ );
544661
545662 let socket_path = instance_dir.join(VM_MANAGER_SOCKET_NAME);
546663 if let Ok(stream) = UnixStream::connect(&socket_path) {