Skip to content

Commit 191a95f

Browse files
authored
Merge pull request #32 from tuchg/codex/fix-autostart-cli-path
fix: persist autostart PATH
2 parents 9f7b89f + 93a7286 commit 191a95f

4 files changed

Lines changed: 158 additions & 3 deletions

File tree

crates/lucarned-ctl/src/autostart/linux.rs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,14 @@ fn unit_path() -> Result<PathBuf, String> {
5555
}
5656

5757
fn render_unit(paths: &AutostartPaths) -> String {
58+
render_unit_with_path(paths, &super::service_path_env())
59+
}
60+
61+
fn render_unit_with_path(paths: &AutostartPaths, path_env: &str) -> String {
5862
format!(
59-
"[Unit]\nDescription=Lucarne daemon\n\n[Service]\nType=simple\nExecStart={}\nRestart=on-failure\nRestartSec=5\n\n[Install]\nWantedBy=default.target\n",
60-
systemd_quote(&paths.lucarned.display().to_string())
63+
"[Unit]\nDescription=Lucarne daemon\n\n[Service]\nType=simple\nExecStart={}\nEnvironment=\"PATH={}\"\nRestart=on-failure\nRestartSec=5\n\n[Install]\nWantedBy=default.target\n",
64+
systemd_quote(&paths.lucarned.display().to_string()),
65+
systemd_env_escape(path_env),
6166
)
6267
}
6368

@@ -72,6 +77,13 @@ fn systemd_quote(input: &str) -> String {
7277
}
7378
}
7479

80+
fn systemd_env_escape(input: &str) -> String {
81+
input
82+
.replace('\\', "\\\\")
83+
.replace('"', "\\\"")
84+
.replace('\n', "\\n")
85+
}
86+
7587
#[cfg(test)]
7688
mod tests {
7789
use super::*;
@@ -85,8 +97,22 @@ mod tests {
8597
};
8698
let unit = render_unit(&paths);
8799
assert!(unit.contains("ExecStart=\"/home/me/My Apps/lucarned\""));
100+
assert!(unit.contains("Environment=\"PATH="));
88101
assert!(unit.contains("Restart=on-failure"));
89102
assert!(unit.contains("RestartSec=5"));
90103
assert!(unit.contains("WantedBy=default.target"));
91104
}
105+
106+
#[test]
107+
fn unit_persists_current_process_path_only() {
108+
let paths = AutostartPaths {
109+
lucarned: PathBuf::from("/home/me/lucarned"),
110+
config_dir: PathBuf::from("/home/me/.lucarned"),
111+
log_dir: PathBuf::from("/home/me/.lucarned/logs"),
112+
};
113+
let unit = render_unit_with_path(&paths, "/custom/bin:/usr/bin");
114+
assert!(unit.contains("Environment=\"PATH=/custom/bin:/usr/bin\""));
115+
assert!(!unit.contains("HOMEBREW_PATH"));
116+
assert!(!unit.contains("LUCARNE_TEST_ENV"));
117+
}
92118
}

crates/lucarned-ctl/src/autostart/macos.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,21 @@ fn uid() -> Result<String, String> {
8383
}
8484

8585
fn render_plist(paths: &AutostartPaths) -> String {
86+
render_plist_with_path(paths, &super::service_path_env())
87+
}
88+
89+
fn render_plist_with_path(paths: &AutostartPaths, path_env: &str) -> String {
8690
format!(
8791
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
8892
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n\
8993
<plist version=\"1.0\">\n\
9094
<dict>\n\
9195
<key>Label</key><string>{}</string>\n\
9296
<key>ProgramArguments</key><array><string>{}</string></array>\n\
97+
<key>EnvironmentVariables</key>\n\
98+
<dict>\n\
99+
<key>PATH</key><string>{}</string>\n\
100+
</dict>\n\
93101
<key>RunAtLoad</key><true/>\n\
94102
<key>KeepAlive</key><false/>\n\
95103
<key>StandardOutPath</key><string>{}</string>\n\
@@ -98,6 +106,7 @@ fn render_plist(paths: &AutostartPaths) -> String {
98106
</plist>\n",
99107
LABEL,
100108
xml_escape(&paths.lucarned.display().to_string()),
109+
xml_escape(path_env),
101110
xml_escape(&paths.log_dir.join("launchd.out.log").display().to_string()),
102111
xml_escape(&paths.log_dir.join("launchd.err.log").display().to_string()),
103112
)
@@ -127,4 +136,30 @@ mod tests {
127136
assert!(plist.contains("/tmp/A&amp;B/lucarned"));
128137
assert!(plist.contains("com.tuchg.lucarned"));
129138
}
139+
140+
#[test]
141+
fn plist_sets_cli_lookup_path() {
142+
let paths = AutostartPaths {
143+
lucarned: PathBuf::from("/tmp/lucarned"),
144+
config_dir: PathBuf::from("/tmp/config"),
145+
log_dir: PathBuf::from("/tmp/logs"),
146+
};
147+
let plist = render_plist(&paths);
148+
assert!(plist.contains("<key>EnvironmentVariables</key>"));
149+
assert!(plist.contains("<key>PATH</key>"));
150+
assert!(plist.contains("/usr/bin"));
151+
}
152+
153+
#[test]
154+
fn plist_persists_current_process_path_only() {
155+
let paths = AutostartPaths {
156+
lucarned: PathBuf::from("/tmp/lucarned"),
157+
config_dir: PathBuf::from("/tmp/config"),
158+
log_dir: PathBuf::from("/tmp/logs"),
159+
};
160+
let plist = render_plist_with_path(&paths, "/custom/bin:/usr/bin");
161+
assert!(plist.contains("<key>PATH</key><string>/custom/bin:/usr/bin</string>"));
162+
assert!(!plist.contains("HOMEBREW_PATH"));
163+
assert!(!plist.contains("LUCARNE_TEST_ENV"));
164+
}
130165
}

crates/lucarned-ctl/src/autostart/mod.rs

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::path::PathBuf;
1+
use std::{ffi::OsString, path::PathBuf};
22

33
use super::process::{run, CommandResult, CommandSpec};
44

@@ -114,3 +114,67 @@ fn format_command_failure(spec: &CommandSpec, result: &CommandResult) -> String
114114
)
115115
}
116116
}
117+
118+
const POSIX_SYSTEM_PATH_ENTRIES: &[&str] = &["/usr/bin", "/bin", "/usr/sbin", "/sbin"];
119+
120+
fn service_path_env() -> String {
121+
service_path_env_from(std::env::var_os("PATH"))
122+
}
123+
124+
fn service_path_env_from(path: Option<OsString>) -> String {
125+
path.filter(|path| !path.is_empty())
126+
.map(|path| path.to_string_lossy().into_owned())
127+
.unwrap_or_else(default_path_env)
128+
}
129+
130+
fn path_list_separator() -> &'static str {
131+
if cfg!(windows) {
132+
";"
133+
} else {
134+
":"
135+
}
136+
}
137+
138+
fn default_path_env() -> String {
139+
POSIX_SYSTEM_PATH_ENTRIES.join(path_list_separator())
140+
}
141+
142+
#[cfg(test)]
143+
mod tests {
144+
use super::service_path_env_from;
145+
use std::ffi::OsString;
146+
147+
#[test]
148+
#[cfg(not(windows))]
149+
fn service_path_env_preserves_current_process_path() {
150+
let path = service_path_env_from(Some(OsString::from("/custom/bin:/usr/bin")));
151+
assert_eq!(path, "/custom/bin:/usr/bin");
152+
}
153+
154+
#[test]
155+
#[cfg(not(windows))]
156+
fn service_path_env_uses_system_path_when_current_path_is_missing() {
157+
let path = service_path_env_from(None);
158+
assert_eq!(path, "/usr/bin:/bin:/usr/sbin:/sbin");
159+
}
160+
161+
#[test]
162+
#[cfg(not(windows))]
163+
fn service_path_env_uses_system_path_when_current_path_is_empty() {
164+
let path = service_path_env_from(Some(OsString::new()));
165+
assert_eq!(path, "/usr/bin:/bin:/usr/sbin:/sbin");
166+
}
167+
168+
#[test]
169+
fn service_path_env_reads_requested_current_process_path() {
170+
if std::env::var("LUCARNE_AUTOSTART_EXPECT_PATH")
171+
.ok()
172+
.as_deref()
173+
!= Some("1")
174+
{
175+
return;
176+
}
177+
let expected = std::env::var("PATH").expect("PATH should be set");
178+
assert_eq!(super::service_path_env(), expected);
179+
}
180+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Autostart CLI PATH
2+
3+
Lucarne resumes quoted WeChat sessions inside `lucarned`. When
4+
`lucarned autostart` starts the daemon through a service manager, the daemon
5+
does not necessarily inherit the user's interactive shell PATH. Agent CLIs can
6+
therefore be visible to commands such as `where claude` in a terminal but
7+
unavailable when the daemon later resumes a quoted session.
8+
9+
Decision: autostart files persist only the `PATH` visible to
10+
`lucarned autostart install`. If `PATH` is unavailable, Lucarne adds the minimal
11+
POSIX system PATH:
12+
13+
`/usr/bin:/bin:/usr/sbin:/sbin`
14+
15+
Lucarne makes the CLI lookup path explicit instead of relying on inheritance
16+
from a login shell. It does not synthesize tool directories of its own, does not
17+
derive `PATH` from other environment variables, and does not persist unrelated
18+
environment variables into the service file.
19+
20+
This belongs to daemon startup ownership. Provider adapters still receive
21+
opaque binary names or configured paths and resolve them through the merged
22+
process environment at launch time.
23+
24+
Rejected alternatives:
25+
26+
- Teach the Claude adapter about one host's concrete `claude` path. That fixes
27+
one provider while leaking host startup policy into provider-specific code.
28+
- Tell users to export `LUCARNE_CLAUDE_BIN` in a shell. LaunchAgent jobs do not
29+
inherit those shell exports, and the same lookup failure can affect other
30+
agent CLIs.

0 commit comments

Comments
 (0)