diff --git a/crates/lucarned-ctl/src/autostart/linux.rs b/crates/lucarned-ctl/src/autostart/linux.rs index 1577ec7..1798753 100644 --- a/crates/lucarned-ctl/src/autostart/linux.rs +++ b/crates/lucarned-ctl/src/autostart/linux.rs @@ -55,9 +55,14 @@ fn unit_path() -> Result { } fn render_unit(paths: &AutostartPaths) -> String { + render_unit_with_path(paths, &super::service_path_env()) +} + +fn render_unit_with_path(paths: &AutostartPaths, path_env: &str) -> String { format!( - "[Unit]\nDescription=Lucarne daemon\n\n[Service]\nType=simple\nExecStart={}\nRestart=on-failure\nRestartSec=5\n\n[Install]\nWantedBy=default.target\n", - systemd_quote(&paths.lucarned.display().to_string()) + "[Unit]\nDescription=Lucarne daemon\n\n[Service]\nType=simple\nExecStart={}\nEnvironment=\"PATH={}\"\nRestart=on-failure\nRestartSec=5\n\n[Install]\nWantedBy=default.target\n", + systemd_quote(&paths.lucarned.display().to_string()), + systemd_env_escape(path_env), ) } @@ -72,6 +77,13 @@ fn systemd_quote(input: &str) -> String { } } +fn systemd_env_escape(input: &str) -> String { + input + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") +} + #[cfg(test)] mod tests { use super::*; @@ -85,8 +97,22 @@ mod tests { }; let unit = render_unit(&paths); assert!(unit.contains("ExecStart=\"/home/me/My Apps/lucarned\"")); + assert!(unit.contains("Environment=\"PATH=")); assert!(unit.contains("Restart=on-failure")); assert!(unit.contains("RestartSec=5")); assert!(unit.contains("WantedBy=default.target")); } + + #[test] + fn unit_persists_current_process_path_only() { + let paths = AutostartPaths { + lucarned: PathBuf::from("/home/me/lucarned"), + config_dir: PathBuf::from("/home/me/.lucarned"), + log_dir: PathBuf::from("/home/me/.lucarned/logs"), + }; + let unit = render_unit_with_path(&paths, "/custom/bin:/usr/bin"); + assert!(unit.contains("Environment=\"PATH=/custom/bin:/usr/bin\"")); + assert!(!unit.contains("HOMEBREW_PATH")); + assert!(!unit.contains("LUCARNE_TEST_ENV")); + } } diff --git a/crates/lucarned-ctl/src/autostart/macos.rs b/crates/lucarned-ctl/src/autostart/macos.rs index 2f0f51e..954d369 100644 --- a/crates/lucarned-ctl/src/autostart/macos.rs +++ b/crates/lucarned-ctl/src/autostart/macos.rs @@ -83,6 +83,10 @@ fn uid() -> Result { } fn render_plist(paths: &AutostartPaths) -> String { + render_plist_with_path(paths, &super::service_path_env()) +} + +fn render_plist_with_path(paths: &AutostartPaths, path_env: &str) -> String { format!( "\n\ \n\ @@ -90,6 +94,10 @@ fn render_plist(paths: &AutostartPaths) -> String { \n\ Label{}\n\ ProgramArguments{}\n\ + EnvironmentVariables\n\ + \n\ + PATH{}\n\ + \n\ RunAtLoad\n\ KeepAlive\n\ StandardOutPath{}\n\ @@ -98,6 +106,7 @@ fn render_plist(paths: &AutostartPaths) -> String { \n", LABEL, xml_escape(&paths.lucarned.display().to_string()), + xml_escape(path_env), xml_escape(&paths.log_dir.join("launchd.out.log").display().to_string()), xml_escape(&paths.log_dir.join("launchd.err.log").display().to_string()), ) @@ -127,4 +136,30 @@ mod tests { assert!(plist.contains("/tmp/A&B/lucarned")); assert!(plist.contains("com.tuchg.lucarned")); } + + #[test] + fn plist_sets_cli_lookup_path() { + let paths = AutostartPaths { + lucarned: PathBuf::from("/tmp/lucarned"), + config_dir: PathBuf::from("/tmp/config"), + log_dir: PathBuf::from("/tmp/logs"), + }; + let plist = render_plist(&paths); + assert!(plist.contains("EnvironmentVariables")); + assert!(plist.contains("PATH")); + assert!(plist.contains("/usr/bin")); + } + + #[test] + fn plist_persists_current_process_path_only() { + let paths = AutostartPaths { + lucarned: PathBuf::from("/tmp/lucarned"), + config_dir: PathBuf::from("/tmp/config"), + log_dir: PathBuf::from("/tmp/logs"), + }; + let plist = render_plist_with_path(&paths, "/custom/bin:/usr/bin"); + assert!(plist.contains("PATH/custom/bin:/usr/bin")); + assert!(!plist.contains("HOMEBREW_PATH")); + assert!(!plist.contains("LUCARNE_TEST_ENV")); + } } diff --git a/crates/lucarned-ctl/src/autostart/mod.rs b/crates/lucarned-ctl/src/autostart/mod.rs index edb6f5c..e547501 100644 --- a/crates/lucarned-ctl/src/autostart/mod.rs +++ b/crates/lucarned-ctl/src/autostart/mod.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::{ffi::OsString, path::PathBuf}; use super::process::{run, CommandResult, CommandSpec}; @@ -114,3 +114,67 @@ fn format_command_failure(spec: &CommandSpec, result: &CommandResult) -> String ) } } + +const POSIX_SYSTEM_PATH_ENTRIES: &[&str] = &["/usr/bin", "/bin", "/usr/sbin", "/sbin"]; + +fn service_path_env() -> String { + service_path_env_from(std::env::var_os("PATH")) +} + +fn service_path_env_from(path: Option) -> String { + path.filter(|path| !path.is_empty()) + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_else(default_path_env) +} + +fn path_list_separator() -> &'static str { + if cfg!(windows) { + ";" + } else { + ":" + } +} + +fn default_path_env() -> String { + POSIX_SYSTEM_PATH_ENTRIES.join(path_list_separator()) +} + +#[cfg(test)] +mod tests { + use super::service_path_env_from; + use std::ffi::OsString; + + #[test] + #[cfg(not(windows))] + fn service_path_env_preserves_current_process_path() { + let path = service_path_env_from(Some(OsString::from("/custom/bin:/usr/bin"))); + assert_eq!(path, "/custom/bin:/usr/bin"); + } + + #[test] + #[cfg(not(windows))] + fn service_path_env_uses_system_path_when_current_path_is_missing() { + let path = service_path_env_from(None); + assert_eq!(path, "/usr/bin:/bin:/usr/sbin:/sbin"); + } + + #[test] + #[cfg(not(windows))] + fn service_path_env_uses_system_path_when_current_path_is_empty() { + let path = service_path_env_from(Some(OsString::new())); + assert_eq!(path, "/usr/bin:/bin:/usr/sbin:/sbin"); + } + + #[test] + fn service_path_env_reads_requested_current_process_path() { + if std::env::var("LUCARNE_AUTOSTART_EXPECT_PATH") + .ok() + .as_deref() + != Some("1") + { + return; + } + let expected = std::env::var("PATH").expect("PATH should be set"); + assert_eq!(super::service_path_env(), expected); + } +} diff --git a/docs/decisions/2026-05-25-macos-launchagent-cli-path.md b/docs/decisions/2026-05-25-macos-launchagent-cli-path.md new file mode 100644 index 0000000..73dc78f --- /dev/null +++ b/docs/decisions/2026-05-25-macos-launchagent-cli-path.md @@ -0,0 +1,30 @@ +# Autostart CLI PATH + +Lucarne resumes quoted WeChat sessions inside `lucarned`. When +`lucarned autostart` starts the daemon through a service manager, the daemon +does not necessarily inherit the user's interactive shell PATH. Agent CLIs can +therefore be visible to commands such as `where claude` in a terminal but +unavailable when the daemon later resumes a quoted session. + +Decision: autostart files persist only the `PATH` visible to +`lucarned autostart install`. If `PATH` is unavailable, Lucarne adds the minimal +POSIX system PATH: + +`/usr/bin:/bin:/usr/sbin:/sbin` + +Lucarne makes the CLI lookup path explicit instead of relying on inheritance +from a login shell. It does not synthesize tool directories of its own, does not +derive `PATH` from other environment variables, and does not persist unrelated +environment variables into the service file. + +This belongs to daemon startup ownership. Provider adapters still receive +opaque binary names or configured paths and resolve them through the merged +process environment at launch time. + +Rejected alternatives: + +- Teach the Claude adapter about one host's concrete `claude` path. That fixes + one provider while leaking host startup policy into provider-specific code. +- Tell users to export `LUCARNE_CLAUDE_BIN` in a shell. LaunchAgent jobs do not + inherit those shell exports, and the same lookup failure can affect other + agent CLIs.