From 2939cdfb3a634f5b927a19de6c8a40401ba1844a Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 15 Nov 2026 01:32:16 +1100 Subject: [PATCH 1/2] fix(desktop): capture login-shell PATH on direct Unix GUI launches Opening Maka.app or maka-desktop-shell from Finder, the Dock, Spotlight or a Linux desktop launcher gives the process a minimal system PATH (such as /usr/bin:/bin:/usr/sbin:/sbin), hiding user-installed CLIs like agy, codex and claude in ~/.local/bin or Homebrew from the Host. Resolve PATH from the user's interactive login shell inside maka-desktop-shell's direct-open bootstrap before spawning maka desktop, when neither TERM nor COLORTERM is set and MAKA_SKIP_SHELL_ENV is not 1. Keep CLI invocations and isolated test sandboxes untouched, import only PATH so startup scripts cannot inject control variables, execute /bin/sh inside the login shell so non-POSIX shells export a colon-delimited PATH, bound output and runtime, and kill the dedicated probe process group on completion or failure. Generated-by: Antigravity --- Cargo.lock | 1 + products/maka/desktop-shell/Cargo.toml | 3 + products/maka/desktop-shell/README.md | 2 +- products/maka/desktop-shell/README.zh-CN.md | 2 +- products/maka/desktop-shell/src/login_path.rs | 361 ++++++++++++++++++ products/maka/desktop-shell/src/main.rs | 6 + .../maka/desktop-shell/tests/open_directly.rs | 233 ++++++++--- 7 files changed, 559 insertions(+), 49 deletions(-) create mode 100644 products/maka/desktop-shell/src/login_path.rs diff --git a/Cargo.lock b/Cargo.lock index 9d0c86b1f9..b390d9d233 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8849,6 +8849,7 @@ dependencies = [ "block2 0.6.2", "dispatch2", "embed-resource", + "libc", "maka-event-log", "maka-host-runtime", "maka-workbench", diff --git a/products/maka/desktop-shell/Cargo.toml b/products/maka/desktop-shell/Cargo.toml index ddfaffc9ee..c1d2ce0a18 100644 --- a/products/maka/desktop-shell/Cargo.toml +++ b/products/maka/desktop-shell/Cargo.toml @@ -35,6 +35,9 @@ uuid.workspace = true [build-dependencies] embed-resource = "3.0" +[target.'cfg(unix)'.dependencies] +libc.workspace = true + [target.'cfg(target_os = "linux")'.dependencies] nsh-platform-linux.workspace = true diff --git a/products/maka/desktop-shell/README.md b/products/maka/desktop-shell/README.md index eae4a0e6b3..44925c3fcf 100644 --- a/products/maka/desktop-shell/README.md +++ b/products/maka/desktop-shell/README.md @@ -46,7 +46,7 @@ Start it with the launcher: `--root` selects the business Host Root and defaults to `~/.maka/application/host`. The launcher initializes a missing Root with the standard read-only business Host or reopens its saved deployment, verifies discovery against the connected Host endpoint, source and epoch, and supplies `MAKA_HOST_ROOT`, `MAKA_HOST_ENDPOINT` and `MAKA_HOST_EPOCH` to the shell, with its own path in `MAKA_LAUNCHER`. The shell admits itself to that Host as the terminal shell does and keeps its client data under the Root. -Opened directly, from Finder, the Dock or a file manager, the shell has none of these variables. It then runs `maka desktop` from the directory it was staged in, beside `Maka.app` on macOS or beside the shell elsewhere, and exits; that launcher opens the window for the default Root. +Opened directly, from Finder, the Dock or a file manager, the shell has none of these variables. On Unix without a terminal environment (unless `MAKA_SKIP_SHELL_ENV=1` is set), it first captures `PATH` from the user's login shell so user-installed CLIs are visible to the Host. It then runs `maka desktop` from the directory it was staged in, beside `Maka.app` on macOS or beside the shell elsewhere, and exits; that launcher opens the window for the default Root. Closing the window hides it and keeps its one UI engine and Host client; the tray's Open brings it back. On Linux without a panel that hosts the tray, closing quits instead, and a panel that leaves brings a hidden window back. Quit from the tray, the application menu or the quit key closes the desktop and launcher sessions. The last client to quit shuts down an ephemeral Host and settles the work it owns. A shared or managed Host continues serving its clients. diff --git a/products/maka/desktop-shell/README.zh-CN.md b/products/maka/desktop-shell/README.zh-CN.md index fd5bd0cfb9..52d36b4f48 100644 --- a/products/maka/desktop-shell/README.zh-CN.md +++ b/products/maka/desktop-shell/README.zh-CN.md @@ -44,7 +44,7 @@ just build `--root` 可选,指定业务 Host Root,默认使用 `~/.maka/application/host`。启动器把缺失的 Root 初始化为标准只读业务 Host,或重开其已保存的部署;随后对照已连接 Host 的端点、来源和 epoch 校验发现信息,并把 `MAKA_HOST_ROOT`、`MAKA_HOST_ENDPOINT` 和 `MAKA_HOST_EPOCH` 传给 shell,同时用 `MAKA_LAUNCHER` 传入启动器自身的路径。shell 以与终端 shell 相同的方式接入该 Host,并把客户端数据保存在该 Root 下。 -从访达、程序坞或文件管理器直接打开时,shell 拿不到这些变量。它会运行所在目录里的 `maka desktop`(macOS 上是 `Maka.app` 旁边,其他平台是 shell 旁边),然后退出;由这个启动器为默认 Root 打开窗口。 +从访达、程序坞或文件管理器直接打开时,shell 拿不到这些变量。Unix 上没有终端环境且未设置 `MAKA_SKIP_SHELL_ENV=1` 时,它会先从用户的登录 shell 读取 `PATH`,使 Host 能找到用户安装的 CLI;随后运行所在目录里的 `maka desktop`(macOS 上是 `Maka.app` 旁边,其他平台是 shell 旁边),然后退出,由这个启动器为默认 Root 打开窗口。 关闭窗口只会隐藏窗口,并保留唯一的 UI 引擎和 Host 客户端;托盘的“打开”可以重新显示窗口。Linux 上如果没有能显示托盘的面板,关闭窗口会直接退出;面板离开时,已隐藏的窗口会重新显示。通过托盘、应用菜单或退出快捷键退出时,桌面和启动器会关闭各自的会话。最后一个客户端退出时,临时 Host 会停止并收尾它持有的工作。共享或受管的 Host 继续为其客户端服务。 diff --git a/products/maka/desktop-shell/src/login_path.rs b/products/maka/desktop-shell/src/login_path.rs new file mode 100644 index 0000000000..d77a73b429 --- /dev/null +++ b/products/maka/desktop-shell/src/login_path.rs @@ -0,0 +1,361 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +use std::{ + ffi::{CStr, OsStr, OsString}, + io::{self, Read}, + os::unix::{ + ffi::{OsStrExt, OsStringExt}, + fs::PermissionsExt, + process::CommandExt, + }, + path::{Path, PathBuf}, + process::{Child, Command, ExitStatus, Stdio}, + sync::mpsc::{self, RecvTimeoutError}, + time::{Duration, Instant}, +}; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_CAPTURE_BYTES: usize = 64 * 1024; + +pub fn resolve() -> Option { + if std::env::var_os("MAKA_SKIP_SHELL_ENV").as_deref() == Some(OsStr::new("1")) + || non_empty("TERM") + || non_empty("COLORTERM") + { + return None; + } + let shell = select_shell( + std::env::var_os("SHELL").as_deref(), + account_shell().as_deref(), + ); + match capture(&shell, DEFAULT_TIMEOUT) { + Ok(path) => Some(path), + Err(error) => { + eprintln!("maka-desktop-shell: login shell PATH: {error}"); + None + } + } +} + +fn non_empty(name: &str) -> bool { + std::env::var_os(name).is_some_and(|value| !value.is_empty()) +} + +fn executable(path: &Path) -> bool { + path.is_absolute() + && std::fs::metadata(path) + .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) +} + +fn select_shell(inherited: Option<&OsStr>, account: Option<&Path>) -> PathBuf { + if let Some(raw) = inherited { + let trimmed = Path::new(OsStr::from_bytes(raw.as_bytes().trim_ascii())); + if executable(trimmed) { + return trimmed.to_owned(); + } + } + if let Some(account) = account.filter(|path| executable(path)) { + return account.to_owned(); + } + PathBuf::from(if cfg!(target_os = "macos") { + "/bin/zsh" + } else { + "/bin/sh" + }) +} + +fn account_shell() -> Option { + let mut buffer = vec![0u8; 65_536]; + let mut entry = std::mem::MaybeUninit::::uninit(); + let mut result = std::ptr::null_mut(); + // SAFETY: out pointers and scratch buffer outlive the call; pw_shell is copied before return. + unsafe { + if libc::getpwuid_r( + libc::geteuid(), + entry.as_mut_ptr(), + buffer.as_mut_ptr().cast(), + buffer.len(), + &mut result, + ) != 0 + || result.is_null() + { + return None; + } + let entry = entry.assume_init(); + if entry.pw_shell.is_null() { + return None; + } + let bytes = CStr::from_ptr(entry.pw_shell).to_bytes().trim_ascii(); + (!bytes.is_empty()).then(|| PathBuf::from(OsStr::from_bytes(bytes))) + } +} + +fn capture_args(shell: &Path, mark: &str) -> (&'static [&'static str], String) { + let name = shell + .file_name() + .and_then(OsStr::to_str) + .unwrap_or_default(); + let prefix = if name == "nu" { "^" } else { "" }; + // Spawn /bin/sh so shells where $PATH is a list (fish, nu, csh) export a colon-delimited PATH. + let script = + format!("{prefix}/bin/sh -c 'printf \"%s%s%s\" \"$1\" \"$PATH\" \"$1\"' sh {mark}"); + let flags: &'static [&'static str] = if matches!(name, "csh" | "tcsh") { + &["-ic"] + } else { + &["-i", "-l", "-c"] + }; + (flags, script) +} + +enum Chunk { + Bytes(Vec), + Overflow, +} + +struct Probe { + child: Child, + pgid: Option, +} + +impl Probe { + fn spawn( + shell: &Path, + flags: &[&str], + script: &str, + ) -> Result<(Self, std::process::ChildStdout), String> { + let mut command = Command::new(shell); + command + .args(flags) + .arg(script) + .env("DISABLE_AUTO_UPDATE", "true") + .env("DISABLE_UPDATE_PROMPT", "true") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + // SAFETY: setsid is async-signal-safe and isolates the interactive shell in its own session/pgrp. + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + let mut child = command + .spawn() + .map_err(|error| format!("{}: {error}", shell.display()))?; + let stdout = child.stdout.take().expect("piped stdout"); + let pgid = libc::pid_t::try_from(child.id()).ok().filter(|&id| id > 0); + Ok((Self { child, pgid }, stdout)) + } + + fn kill_group(&mut self) { + if let Some(pgid) = self.pgid.take() { + // SAFETY: setsid made child the group leader; -pgid signals only that dedicated group. + unsafe { + libc::kill(-pgid, libc::SIGKILL); + } + } + } + + fn try_wait(&mut self, shell: &Path) -> Result, String> { + let status = self + .child + .try_wait() + .map_err(|error| format!("{}: {error}", shell.display()))?; + if status.is_some() { + self.kill_group(); + } + Ok(status) + } +} + +impl Drop for Probe { + fn drop(&mut self) { + self.kill_group(); + let _ = self.child.wait(); + } +} + +fn capture(shell: &Path, timeout: Duration) -> Result { + let mark = format!("__MAKA_PATH_{}__", uuid::Uuid::new_v4().simple()); + let (flags, script) = capture_args(shell, &mark); + let (mut probe, mut stdout) = Probe::spawn(shell, flags, &script)?; + let (tx, rx) = mpsc::sync_channel(16); + std::thread::spawn(move || { + let mut total = 0usize; + let mut chunk = [0u8; 4096]; + while let Ok(count) = stdout.read(&mut chunk) { + if count == 0 { + break; + } + total += count; + if total > MAX_CAPTURE_BYTES { + let _ = tx.send(Chunk::Overflow); + break; + } + if tx.send(Chunk::Bytes(chunk[..count].to_vec())).is_err() { + break; + } + } + }); + + let deadline = Instant::now() + timeout; + let mut output = Vec::new(); + let mut eof = false; + loop { + if let Some(parsed) = extract(&output, mark.as_bytes()) { + let path = parsed.map_err(str::to_owned)?; + let settle = (Instant::now() + Duration::from_millis(100)).min(deadline); + loop { + if let Some(status) = probe.try_wait(shell)? { + return if status.success() { + Ok(path) + } else { + Err(format!("{} exited with {status}", shell.display())) + }; + } + if Instant::now() >= settle { + return Ok(path); + } + std::thread::sleep(Duration::from_millis(5)); + } + } + if eof && let Some(status) = probe.try_wait(shell)? { + return if status.success() { + Err(format!("missing PATH markers from {}", shell.display())) + } else { + Err(format!("{} exited with {status}", shell.display())) + }; + } + let now = Instant::now(); + if now >= deadline { + return Err(format!("timed out waiting for {}", shell.display())); + } + if eof { + std::thread::sleep(Duration::from_millis(5).min(deadline - now)); + continue; + } + match rx.recv_timeout((deadline - now).min(Duration::from_millis(50))) { + Ok(Chunk::Bytes(bytes)) => output.extend_from_slice(&bytes), + Ok(Chunk::Overflow) => { + return Err(format!( + "{} output exceeded {MAX_CAPTURE_BYTES} bytes", + shell.display() + )); + } + Err(RecvTimeoutError::Disconnected) => eof = true, + Err(RecvTimeoutError::Timeout) => {} + } + } +} + +fn extract(output: &[u8], mark: &[u8]) -> Option> { + let start = output + .windows(mark.len()) + .position(|window| window == mark)? + + mark.len(); + let rest = &output[start..]; + let end = rest.windows(mark.len()).position(|window| window == mark)?; + let bytes = &rest[..end]; + if bytes.is_empty() || bytes.contains(&0) { + return Some(Err("login shell returned an empty or invalid PATH")); + } + Some(Ok(OsString::from_vec(bytes.to_vec()))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shell_selection_falls_back_through_executable_candidates() { + let dir = std::env::temp_dir().join(format!("maka-login-path-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let inherited = dir.join("inherited-sh"); + let account = dir.join("account-sh"); + std::fs::write(&inherited, "#!/bin/sh\n").unwrap(); + std::fs::write(&account, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&inherited, std::fs::Permissions::from_mode(0o700)).unwrap(); + std::fs::set_permissions(&account, std::fs::Permissions::from_mode(0o700)).unwrap(); + + let padded = OsString::from(format!(" {} \n", inherited.display())); + assert_eq!(select_shell(Some(&padded), Some(&account)), inherited); + + std::fs::set_permissions(&inherited, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert_eq!(select_shell(Some(&padded), Some(&account)), account); + assert_eq!( + select_shell(Some(OsStr::new("relative/sh")), Some(&account)), + account + ); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn timeout_kills_hung_shell_and_its_process_group() { + let dir = std::env::temp_dir().join(format!("maka-login-timeout-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let shell = dir.join("hung-shell"); + let pid_file = dir.join("child.pid"); + std::fs::write( + &shell, + format!( + "#!/bin/sh\n[ \"$1\" = warmup ] && exit 0\n( trap '' HUP TERM; sleep 60 ) /dev/null 2>&1 &\nprintf '%s' \"$!\" > '{}'\nwait\n", + pid_file.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert!( + Command::new(&shell) + .arg("warmup") + .status() + .unwrap() + .success() + ); + + let error = capture(&shell, Duration::from_millis(500)).unwrap_err(); + assert!(error.contains("timed out"), "{error}"); + + let pid: libc::pid_t = std::fs::read_to_string(&pid_file) + .unwrap() + .trim() + .parse() + .unwrap(); + let started = Instant::now(); + while started.elapsed() < Duration::from_secs(5) { + // SAFETY: signal 0 only probes process existence. + if unsafe { libc::kill(pid, 0) } != 0 + && io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) + { + let _ = std::fs::remove_dir_all(&dir); + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + // SAFETY: clean up leaked test child before failing. + unsafe { + libc::kill(pid, libc::SIGKILL); + } + let _ = std::fs::remove_dir_all(&dir); + panic!("descendant {pid} survived timeout cleanup"); + } +} diff --git a/products/maka/desktop-shell/src/main.rs b/products/maka/desktop-shell/src/main.rs index 7c61276217..db7e289f98 100644 --- a/products/maka/desktop-shell/src/main.rs +++ b/products/maka/desktop-shell/src/main.rs @@ -46,6 +46,8 @@ use direct2d as backend; mod host; #[cfg(target_os = "linux")] mod linux; +#[cfg(unix)] +mod login_path; #[cfg(target_os = "linux")] mod placement; #[cfg(any(target_os = "linux", target_os = "macos", windows))] @@ -195,6 +197,10 @@ fn launch() -> Result<(), String> { .join(if cfg!(windows) { "maka.exe" } else { "maka" }); let mut command = std::process::Command::new(&launcher); command.arg("desktop"); + #[cfg(unix)] + if let Some(path) = login_path::resolve() { + command.env("PATH", path); + } #[cfg(windows)] { use std::os::windows::process::CommandExt; diff --git a/products/maka/desktop-shell/tests/open_directly.rs b/products/maka/desktop-shell/tests/open_directly.rs index 0d9f3dbab5..6f94becd05 100644 --- a/products/maka/desktop-shell/tests/open_directly.rs +++ b/products/maka/desktop-shell/tests/open_directly.rs @@ -21,58 +21,197 @@ use std::{ os::unix::fs::PermissionsExt, - path::PathBuf, - process::Command, + path::{Path, PathBuf}, + process::{Command, Output}, time::{Duration, Instant}, }; +struct Staged { + directory: PathBuf, + shell: PathBuf, + recorded: PathBuf, +} + +impl Staged { + fn new(case: &str) -> Self { + let source = std::env::var_os("CARGO_BIN_EXE_maka-desktop-shell") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(env!("CARGO_BIN_EXE_maka-desktop-shell"))); + let directory = source.parent().unwrap().join(format!( + "desktop-open-directly-{}-{case}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&directory); + let shell = directory.join(if cfg!(target_os = "macos") { + "Maka.app/Contents/MacOS/maka-desktop-shell" + } else { + "maka-desktop-shell" + }); + std::fs::create_dir_all(shell.parent().unwrap()).unwrap(); + std::fs::hard_link(&source, &shell).unwrap(); + let recorded = directory.join("recorded"); + write_script( + &directory.join("maka"), + &format!( + "#!/bin/sh\nprintf '%s\\n%s\\n%s\\n' \"$*\" \"$PATH\" \"${{MAKA_TEST_LEAKED:-absent}}\" > '{}'\n", + recorded.display() + ), + ); + Self { + directory, + shell, + recorded, + } + } + + fn command(&self) -> Command { + let mut command = Command::new(&self.shell); + command + .env_remove("MAKA_HOST_ROOT") + .env_remove("MAKA_HOST_ENDPOINT") + .env_remove("MAKA_HOST_EPOCH") + .env_remove("MAKA_LAUNCHER") + .env_remove("TERM") + .env_remove("COLORTERM") + .env_remove("MAKA_SKIP_SHELL_ENV") + .env_remove("MAKA_TEST_LEAKED") + .env("PATH", "/usr/bin:/bin"); + command + } + + fn run(&self, mut command: Command) -> (Output, String) { + let output = command.output().unwrap(); + assert!(output.status.success(), "{output:?}"); + let started = Instant::now(); + let recorded = loop { + match std::fs::read_to_string(&self.recorded) { + Ok(text) if text.lines().count() >= 3 => break text, + _ if started.elapsed() < Duration::from_secs(10) => { + std::thread::sleep(Duration::from_millis(20)) + } + other => panic!("the launcher beside the shell did not run: {other:?}"), + } + }; + (output, recorded) + } +} + +impl Drop for Staged { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.directory); + } +} + +fn write_script(path: &Path, body: &str) { + std::fs::write(path, body).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap(); +} + +fn wait_for_process_exit(pid: libc::pid_t) { + let started = Instant::now(); + while started.elapsed() < Duration::from_secs(5) { + // SAFETY: signal 0 only probes process existence. + if unsafe { libc::kill(pid, 0) } != 0 + && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) + { + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + // SAFETY: clean up leaked test child before failing. + unsafe { + libc::kill(pid, libc::SIGKILL); + } + panic!("login shell descendant {pid} survived shell PATH capture"); +} + #[test] -fn a_shell_opened_without_the_launcher_runs_maka_desktop_beside_it() { - let source = std::env::var_os("CARGO_BIN_EXE_maka-desktop-shell") - .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(env!("CARGO_BIN_EXE_maka-desktop-shell"))); - let directory = source - .parent() - .unwrap() - .join(format!("desktop-open-directly-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&directory); - let shell = directory.join(if cfg!(target_os = "macos") { - "Maka.app/Contents/MacOS/maka-desktop-shell" - } else { - "maka-desktop-shell" - }); - std::fs::create_dir_all(shell.parent().unwrap()).unwrap(); - std::fs::hard_link(&source, &shell).unwrap(); - let launcher = directory.join("maka"); - let arguments = directory.join("arguments"); - std::fs::write( - &launcher, - format!( - "#!/bin/sh\nprintf '%s\\n' \"$@\" > '{}'\n", - arguments.display() +fn gui_launch_imports_only_login_shell_path_and_cleans_up_descendants() { + let staged = Staged::new("login-path"); + let fake_shell = staged.directory.join("zsh"); + let pid_file = staged.directory.join("descendant.pid"); + write_script( + &fake_shell, + &format!( + "#!/bin/sh\n\ + [ \"$1\" = \"-i\" ] && [ \"$2\" = \"-l\" ] && [ \"$3\" = \"-c\" ] || exit 9\n\ + ( trap '' HUP TERM; sleep 60 ) /dev/null 2>&1 &\n\ + printf '%s' \"$!\" > '{}'\n\ + printf '● noisy startup banner\\n'\n\ + printf 'secret-from-stderr\\n' >&2\n\ + export PATH='/resolved/from/login:/usr/bin:/bin'\n\ + export MAKA_TEST_LEAKED='leaked'\n\ + exec /bin/sh -c \"$4\"\n", + pid_file.display() ), - ) - .unwrap(); - std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o700)).unwrap(); + ); - let status = Command::new(&shell) - .env_remove("MAKA_HOST_ROOT") - .env_remove("MAKA_HOST_ENDPOINT") - .env_remove("MAKA_HOST_EPOCH") - .env_remove("MAKA_LAUNCHER") - .status() + let mut command = staged.command(); + command.env("SHELL", &fake_shell); + let (output, recorded) = staged.run(command); + assert!( + !String::from_utf8_lossy(&output.stderr).contains("secret-from-stderr"), + "stderr leaked: {:?}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + recorded, + "desktop\n/resolved/from/login:/usr/bin:/bin\nabsent\n" + ); + + let pid: libc::pid_t = std::fs::read_to_string(&pid_file) + .unwrap() + .trim() + .parse() .unwrap(); - assert!(status.success(), "{status}"); - let started = Instant::now(); - let recorded = loop { - match std::fs::read_to_string(&arguments) { - Ok(text) if !text.is_empty() => break text, - _ if started.elapsed() < Duration::from_secs(10) => { - std::thread::sleep(Duration::from_millis(20)) - } - other => panic!("the launcher beside the shell did not run: {other:?}"), - } - }; - assert_eq!(recorded, "desktop\n"); - std::fs::remove_dir_all(&directory).unwrap(); + wait_for_process_exit(pid); +} + +#[test] +fn terminal_and_opted_out_launches_skip_login_shell_probe() { + for (case, key, value) in [ + ("term", "TERM", "xterm-256color"), + ("colorterm", "COLORTERM", "truecolor"), + ("skip", "MAKA_SKIP_SHELL_ENV", "1"), + ] { + let staged = Staged::new(case); + let sentinel = staged.directory.join("ran"); + let fake_shell = staged.directory.join("zsh"); + write_script( + &fake_shell, + &format!( + "#!/bin/sh\nprintf 'ran' > '{}'\nexit 1\n", + sentinel.display() + ), + ); + let mut command = staged.command(); + command.env("SHELL", &fake_shell).env(key, value); + let (_, recorded) = staged.run(command); + assert_eq!(recorded, "desktop\n/usr/bin:/bin\nabsent\n"); + assert!(!sentinel.exists(), "{case} unexpectedly ran login shell"); + } +} + +#[test] +fn failed_or_flooding_login_shell_falls_back_to_inherited_path() { + for (case, body) in [ + ( + "failed", + "#!/bin/sh\nprintf 'secret-startup-token' >&2\nexit 23\n", + ), + ( + "flood", + "#!/bin/sh\nwhile :; do printf '0123456789abcdef0123456789abcdef'; done\n", + ), + ] { + let staged = Staged::new(case); + let fake_shell = staged.directory.join("zsh"); + write_script(&fake_shell, body); + let mut command = staged.command(); + command.env("SHELL", &fake_shell); + let (output, recorded) = staged.run(command); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stderr.contains("secret-startup-token"), "{stderr}"); + assert_eq!(recorded, "desktop\n/usr/bin:/bin\nabsent\n"); + } } From c9511ca04b4af5edcdc45f985743b7f3c9be0f25 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 15 Nov 2026 02:13:41 +1100 Subject: [PATCH 2/2] fix(desktop): invoke csh/tcsh as login shells and drop settle poll Pass a leading-hyphen argv[0] when probing csh/tcsh so ~/.login is sourced alongside ~/.cshrc, and return as soon as the paired UUID markers arrive instead of polling child exit for up to 100ms. Generated-by: Antigravity --- products/maka/desktop-shell/src/login_path.rs | 76 ++++++------------- .../maka/desktop-shell/tests/open_directly.rs | 31 ++++++++ 2 files changed, 54 insertions(+), 53 deletions(-) diff --git a/products/maka/desktop-shell/src/login_path.rs b/products/maka/desktop-shell/src/login_path.rs index d77a73b429..60a4113992 100644 --- a/products/maka/desktop-shell/src/login_path.rs +++ b/products/maka/desktop-shell/src/login_path.rs @@ -26,7 +26,7 @@ use std::{ process::CommandExt, }, path::{Path, PathBuf}, - process::{Child, Command, ExitStatus, Stdio}, + process::{Child, Command, Stdio}, sync::mpsc::{self, RecvTimeoutError}, time::{Duration, Instant}, }; @@ -107,23 +107,6 @@ fn account_shell() -> Option { } } -fn capture_args(shell: &Path, mark: &str) -> (&'static [&'static str], String) { - let name = shell - .file_name() - .and_then(OsStr::to_str) - .unwrap_or_default(); - let prefix = if name == "nu" { "^" } else { "" }; - // Spawn /bin/sh so shells where $PATH is a list (fish, nu, csh) export a colon-delimited PATH. - let script = - format!("{prefix}/bin/sh -c 'printf \"%s%s%s\" \"$1\" \"$PATH\" \"$1\"' sh {mark}"); - let flags: &'static [&'static str] = if matches!(name, "csh" | "tcsh") { - &["-ic"] - } else { - &["-i", "-l", "-c"] - }; - (flags, script) -} - enum Chunk { Bytes(Vec), Overflow, @@ -135,14 +118,22 @@ struct Probe { } impl Probe { - fn spawn( - shell: &Path, - flags: &[&str], - script: &str, - ) -> Result<(Self, std::process::ChildStdout), String> { + fn spawn(shell: &Path, mark: &str) -> Result<(Self, std::process::ChildStdout), String> { + let name = shell + .file_name() + .and_then(OsStr::to_str) + .unwrap_or_default(); + let prefix = if name == "nu" { "^" } else { "" }; + // Spawn /bin/sh so shells where $PATH is a list (fish, nu, csh) export a colon-delimited PATH. + let script = + format!("{prefix}/bin/sh -c 'printf \"%s%s%s\" \"$1\" \"$PATH\" \"$1\"' sh {mark}"); let mut command = Command::new(shell); + if matches!(name, "csh" | "tcsh") { + command.arg0(format!("-{name}")).arg("-ic"); + } else { + command.args(["-i", "-l", "-c"]); + } command - .args(flags) .arg(script) .env("DISABLE_AUTO_UPDATE", "true") .env("DISABLE_UPDATE_PROMPT", "true") @@ -174,17 +165,6 @@ impl Probe { } } } - - fn try_wait(&mut self, shell: &Path) -> Result, String> { - let status = self - .child - .try_wait() - .map_err(|error| format!("{}: {error}", shell.display()))?; - if status.is_some() { - self.kill_group(); - } - Ok(status) - } } impl Drop for Probe { @@ -196,8 +176,7 @@ impl Drop for Probe { fn capture(shell: &Path, timeout: Duration) -> Result { let mark = format!("__MAKA_PATH_{}__", uuid::Uuid::new_v4().simple()); - let (flags, script) = capture_args(shell, &mark); - let (mut probe, mut stdout) = Probe::spawn(shell, flags, &script)?; + let (mut probe, mut stdout) = Probe::spawn(shell, &mark)?; let (tx, rx) = mpsc::sync_channel(16); std::thread::spawn(move || { let mut total = 0usize; @@ -222,23 +201,14 @@ fn capture(shell: &Path, timeout: Duration) -> Result { let mut eof = false; loop { if let Some(parsed) = extract(&output, mark.as_bytes()) { - let path = parsed.map_err(str::to_owned)?; - let settle = (Instant::now() + Duration::from_millis(100)).min(deadline); - loop { - if let Some(status) = probe.try_wait(shell)? { - return if status.success() { - Ok(path) - } else { - Err(format!("{} exited with {status}", shell.display())) - }; - } - if Instant::now() >= settle { - return Ok(path); - } - std::thread::sleep(Duration::from_millis(5)); - } + return parsed.map_err(str::to_owned); } - if eof && let Some(status) = probe.try_wait(shell)? { + if eof + && let Some(status) = probe + .child + .try_wait() + .map_err(|error| format!("{}: {error}", shell.display()))? + { return if status.success() { Err(format!("missing PATH markers from {}", shell.display())) } else { diff --git a/products/maka/desktop-shell/tests/open_directly.rs b/products/maka/desktop-shell/tests/open_directly.rs index 6f94becd05..0215df2f8e 100644 --- a/products/maka/desktop-shell/tests/open_directly.rs +++ b/products/maka/desktop-shell/tests/open_directly.rs @@ -215,3 +215,34 @@ fn failed_or_flooding_login_shell_falls_back_to_inherited_path() { assert_eq!(recorded, "desktop\n/usr/bin:/bin\nabsent\n"); } } + +#[test] +fn csh_login_shell_loads_cshrc_and_login() { + let Some(csh) = ["/bin/tcsh", "/bin/csh", "/usr/bin/tcsh", "/usr/bin/csh"] + .into_iter() + .map(Path::new) + .find(|candidate| candidate.is_file()) + else { + return; + }; + + let staged = Staged::new("csh-login"); + std::fs::write( + staged.directory.join(".cshrc"), + "setenv PATH /from-cshrc:/usr/bin:/bin\n", + ) + .unwrap(); + std::fs::write( + staged.directory.join(".login"), + "setenv PATH /from-login:$PATH\n", + ) + .unwrap(); + + let mut command = staged.command(); + command.env("HOME", &staged.directory).env("SHELL", csh); + let (_, recorded) = staged.run(command); + assert_eq!( + recorded, + "desktop\n/from-login:/from-cshrc:/usr/bin:/bin\nabsent\n" + ); +}