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..60a4113992 --- /dev/null +++ b/products/maka/desktop-shell/src/login_path.rs @@ -0,0 +1,331 @@ +/* + * 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, 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))) + } +} + +enum Chunk { + Bytes(Vec), + Overflow, +} + +struct Probe { + child: Child, + pgid: Option, +} + +impl Probe { + 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 + .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); + } + } + } +} + +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 (mut probe, mut stdout) = Probe::spawn(shell, &mark)?; + 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()) { + return parsed.map_err(str::to_owned); + } + 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 { + 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..0215df2f8e 100644 --- a/products/maka/desktop-shell/tests/open_directly.rs +++ b/products/maka/desktop-shell/tests/open_directly.rs @@ -21,58 +21,228 @@ 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() +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() + ), + ); + + 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() - .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() + .trim() + .parse() + .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"); + } +} + +#[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::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() - .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(); + 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" + ); }