diff --git a/docs/USAGE.en-GB.md b/docs/USAGE.en-GB.md index 0ebb825..5f8fe26 100644 --- a/docs/USAGE.en-GB.md +++ b/docs/USAGE.en-GB.md @@ -163,6 +163,16 @@ cbth daemon stop Mutating job, batch, task, and maintenance commands route through the same-user daemon by default. Read-only inspection commands read the local store directly. +Service autostart commands: + +```bash +cbth service install +cbth service status +cbth service uninstall +``` + +On macOS, `service install` writes and loads a user LaunchAgent under `~/Library/LaunchAgents` that starts `cbth --home service run` at login. `service status --json` reports the LaunchAgent plist path, launchd target, load state, and generated program arguments. Linux user systemd support remains a documented future shape. + ## Desktop Bridge Commands Desktop bridge commands currently expose operator/helper surfaces, not an enabled Desktop automatic delivery path. diff --git a/docs/USAGE.zh-CN.md b/docs/USAGE.zh-CN.md index 85c6220..0301c30 100644 --- a/docs/USAGE.zh-CN.md +++ b/docs/USAGE.zh-CN.md @@ -163,6 +163,16 @@ cbth daemon stop Mutating job、batch、task 和 maintenance commands 默认通过同用户 daemon 执行。Read-only inspection commands 直接读取本地 store。 +Service 自启动命令: + +```bash +cbth service install +cbth service status +cbth service uninstall +``` + +在 macOS 上,`service install` 会在 `~/Library/LaunchAgents` 下写入并加载用户级 LaunchAgent,登录时启动 `cbth --home service run`。`service status --json` 会报告 LaunchAgent plist 路径、launchd target、加载状态和生成的 program arguments。Linux user systemd support 仍是文档化的后续形状。 + ## Desktop Bridge Commands Desktop bridge commands 当前只是 operator/helper surfaces,不是已启用的 Desktop 自动投递路径。 diff --git a/docs/design/HOST_PLUGIN_RUNTIME_AND_DELIVERY.md b/docs/design/HOST_PLUGIN_RUNTIME_AND_DELIVERY.md index 986cd77..643fd02 100644 --- a/docs/design/HOST_PLUGIN_RUNTIME_AND_DELIVERY.md +++ b/docs/design/HOST_PLUGIN_RUNTIME_AND_DELIVERY.md @@ -147,18 +147,23 @@ service 只接受已通过 `plugin.hello` 且仍匹配当前 supervisor process ### Install And Autostart -`cbth service` 后续应提供: +`cbth service` 的 macOS v1 install/manage surface 提供: ```text cbth service install cbth service uninstall cbth service status +``` + +后续 plugin lifecycle surface 继续预留: + +```text cbth plugin enable cbth plugin disable cbth plugin status ``` -macOS v1 用 `LaunchAgent` 登录自启动。Linux 后续可加 user `systemd`。`LaunchAgent` 应启动 `cbth service run`,而不是直接启动 Webex worker。 +macOS v1 用 `LaunchAgent` 登录自启动。Linux 后续可加 user `systemd`。`LaunchAgent` 启动 `cbth service run`,而不是直接启动 Webex worker。 ### Crash Recovery diff --git a/docs/project_journal/2026/05/2026-05-19-c6-service-install-manage-99aaa7f.md b/docs/project_journal/2026/05/2026-05-19-c6-service-install-manage-99aaa7f.md new file mode 100644 index 0000000..018d01a --- /dev/null +++ b/docs/project_journal/2026/05/2026-05-19-c6-service-install-manage-99aaa7f.md @@ -0,0 +1,50 @@ +--- +id: 20260519-99aaa7f-c6-service-install-manage +title: C6 Service Install Manage +status: completed +created: 2026-05-19 +updated: 2026-05-19 +branch: codex/c6-service-install-manage +pr: https://github.com/JoeyTeng/codex-background-task-handler/pull/92 +supersedes: +superseded_by: +--- + +# C6 Service Install Manage + +## Summary + +- C6 在 `cbth` 中增加 operator-facing `cbth service install`、`cbth service uninstall` 和 `cbth service status`。 +- macOS v1 使用用户级 LaunchAgent 登录自启动,plist 启动 `cbth --home service run`,不会直接启动 Webex worker 或任何 plugin worker。 +- Linux user systemd 仍保留为设计文档中的后续形状,本 PR 不引入 systemd runtime 管理。 + +## Current State + +- `cbth service install` 会写入 `~/Library/LaunchAgents/com.joeyteng.cbth.service.plist`,必要时 reload 已加载的 LaunchAgent,并在 `launchctl print` 确认加载后才返回成功。 +- install 流程会在 bootstrap 前 `launchctl enable gui//com.joeyteng.cbth.service`,用于恢复曾被用户或系统 disabled 的登录自启动;fresh install 下若 launchd 尚未认识该 label,则在 bootstrap 成功后再次 enable。 +- LaunchAgent plist 中的 `ProgramArguments`、stdout log 和 stderr log 路径都会写成绝对路径,避免 launchd 工作目录影响相对 `CBTH_HOME`。 +- `cbth service uninstall` 会 best-effort `launchctl bootout` 用户 LaunchAgent,并删除 plist。 +- `cbth service status` 不启动 service,只报告 plist path、launchd target、load state 和生成的 program arguments;macOS headless / SSH 场景中缺少 `gui/` launchd domain 时按 not-loaded 处理;非 macOS 平台返回 unsupported/future-shape status。 + +## Next Steps + +- C7 继续负责 plugin release manager、shadow/quiesce/drain/promote/rollback 和可选 handoff hooks;C6 不修改这些 lifecycle upgrade internals。 +- 后续 Linux user systemd 支持应沿用同一 operator surface,但在新的平台 slice 中实现。 + +## Evidence + +- Base branch head: `99aaa7f` +- Branch: `codex/c6-service-install-manage` +- PR: https://github.com/JoeyTeng/codex-background-task-handler/pull/92 +- Validation: + - `cargo fmt --check` + - `cargo test --lib service::tests -- --test-threads=1` + - `cargo test --test cli_help -- --test-threads=1` + - `cargo clippy --locked --all-targets -- -D warnings` + - `cargo test --locked` + - `uv run /Users/hoteng/.codex/skills/project-journal/scripts/project_journal.py validate --repo .` +- Local review: + - helper-backed `codex-review` stateful live-snapshot review: no blocking findings. + - frozen-diff review on `99aaa7f..042aef6` found LaunchAgent log path normalization issue; fixed before final PR readiness rerun. + - online `codex/review-gate` on `042aef6` found missing `launchctl enable`; fixed before final PR readiness rerun. + - frozen-diff review on `99aaa7f..5a1593c` found missing launchd-domain handling for status/uninstall; fixed before final PR readiness rerun. diff --git a/src/cli.rs b/src/cli.rs index 6eedf6c..e9b5380 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -57,7 +57,10 @@ use crate::models::{ use crate::self_update::{ SelfUpdateOptions, current_release_target_triple, run_self_update, run_self_update_interactive, }; -use crate::service::{ServiceRunOptions, service_run, status_report, write_status_human}; +use crate::service::{ + ServiceRunOptions, service_install, service_run, service_status, service_uninstall, + status_report, write_service_management_human, write_status_human, +}; use crate::store::{Store, new_id}; const MAX_METADATA_BYTES: u64 = 1024 * 1024; @@ -2300,10 +2303,34 @@ enum DaemonCommand { #[derive(Debug, Subcommand)] enum ServiceCommand { + #[command(about = "Install and load the cbth macOS LaunchAgent")] + Install(ServiceInstallArgs), + #[command(about = "Unload and remove the cbth macOS LaunchAgent")] + Uninstall(ServiceUninstallArgs), + #[command(about = "Inspect cbth service autostart state")] + Status(ServiceStatusArgs), #[command(about = "Run the host plugin supervisor in the foreground")] Run(ServiceRunArgs), } +#[derive(Debug, Args)] +struct ServiceInstallArgs { + #[arg(long, help = "Emit JSON output")] + json: bool, +} + +#[derive(Debug, Args)] +struct ServiceUninstallArgs { + #[arg(long, help = "Emit JSON output")] + json: bool, +} + +#[derive(Debug, Args)] +struct ServiceStatusArgs { + #[arg(long, help = "Emit JSON output")] + json: bool, +} + #[derive(Debug, Args)] struct ServiceRunArgs { #[arg(long, hide = true)] @@ -2445,6 +2472,27 @@ pub fn run() -> Result<()> { write_status_human(&report)?; return Ok(()); } + Commands::Service { + command: ServiceCommand::Install(args), + } if !args.json => { + let report = service_install(&layout)?; + write_service_management_human(&report)?; + return Ok(()); + } + Commands::Service { + command: ServiceCommand::Uninstall(args), + } if !args.json => { + let report = service_uninstall(&layout)?; + write_service_management_human(&report)?; + return Ok(()); + } + Commands::Service { + command: ServiceCommand::Status(args), + } if !args.json => { + let report = service_status(&layout)?; + write_service_management_human(&report)?; + return Ok(()); + } Commands::New(args) => { if cli.direct_store { bail!("new does not support --direct-store"); @@ -13713,6 +13761,18 @@ fn dispatch_daemon(command: DaemonCommand, layout: &FsLayout) -> Result { fn dispatch_service(command: ServiceCommand, layout: &FsLayout) -> Result { match command { + ServiceCommand::Install(_) => { + let report = service_install(layout)?; + Ok(json!({ "service_install": report })) + } + ServiceCommand::Uninstall(_) => { + let report = service_uninstall(layout)?; + Ok(json!({ "service_uninstall": report })) + } + ServiceCommand::Status(_) => { + let report = service_status(layout)?; + Ok(json!({ "service_status": report })) + } ServiceCommand::Run(args) => service_run( layout, ServiceRunOptions { diff --git a/src/service.rs b/src/service.rs index e57b3e7..f0fa533 100644 --- a/src/service.rs +++ b/src/service.rs @@ -1,9 +1,10 @@ use std::collections::{HashMap, HashSet}; +use std::env; use std::fs::{self, OpenOptions}; use std::io::{self, Read, Write}; #[cfg(any(target_os = "linux", target_os = "macos"))] use std::os::fd::AsRawFd; -use std::os::unix::fs::{FileTypeExt, MetadataExt}; +use std::os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt, PermissionsExt}; use std::os::unix::net::{UnixListener, UnixStream}; use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; @@ -79,6 +80,10 @@ const PLUGIN_DELIVERY_APP_SERVER_LEASE_TTL_SECONDS: u64 = const PLUGIN_TERM_GRACE: Duration = Duration::from_millis(500); const PLUGIN_KILL_GRACE: Duration = Duration::from_secs(2); const PLUGIN_HEALTH_UPDATE_METHOD: &str = "plugin.health.update"; +const SERVICE_LAUNCH_AGENT_LABEL: &str = "com.joeyteng.cbth.service"; +const SERVICE_LAUNCH_AGENT_PLIST_NAME: &str = "com.joeyteng.cbth.service.plist"; +const SERVICE_LAUNCH_AGENT_STDOUT_LOG: &str = "service-launchd.stdout.log"; +const SERVICE_LAUNCH_AGENT_STDERR_LOG: &str = "service-launchd.stderr.log"; static SERVICE_TERMINATION_REQUESTED: AtomicBool = AtomicBool::new(false); static PLUGIN_APP_SERVER_CONNECTION_COUNTER: AtomicU64 = AtomicU64::new(1); @@ -184,6 +189,46 @@ pub struct PluginStatusReport { pub plugins: Vec, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct ServiceManagementReport { + pub action: String, + pub platform: String, + pub manager: String, + pub supported: bool, + pub label: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub domain: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub target: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub launch_agent_path: Option, + pub installed: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub loaded: Option, + pub changed: bool, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub program_arguments: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub launchctl: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub struct LaunchctlServiceStatus { + pub loaded: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub state: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_exit_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + struct SupervisedPlugin { manifest: PluginManifest, process: Option, @@ -365,6 +410,565 @@ pub fn write_status_human(report: &PluginStatusReport) -> Result<()> { Ok(()) } +pub fn service_install(layout: &FsLayout) -> Result { + if !service_management_supported() { + bail!("{}", unsupported_service_management_message("install")); + } + let mut context = service_management_context(layout)?; + layout.ensure_run_dir()?; + write_launch_agent_plist_if_changed(&context)?; + let enable_target_found = launchctl_enable(&context)?; + + let before = launchctl_query(&context)?; + if before.loaded && context.plist_changed { + launchctl_bootout(&context)?; + } + if !before.loaded || context.plist_changed { + launchctl_bootstrap(&context)?; + if !enable_target_found && !launchctl_enable(&context)? { + bail!( + "launchctl enable did not recognize {} after bootstrap", + context.target + ); + } + context.plist_changed = true; + } + + let launchctl = launchctl_query(&context)?; + if !launchctl.loaded { + bail!( + "LaunchAgent plist was written but launchctl did not report {} loaded: {}", + context.target, + launchctl.detail.as_deref().unwrap_or("no launchctl detail") + ); + } + Ok(context.into_report( + "install", + true, + Some(launchctl.loaded), + Some(launchctl), + Some("LaunchAgent installed and loaded".to_owned()), + )) +} + +pub fn service_uninstall(layout: &FsLayout) -> Result { + if !service_management_supported() { + bail!("{}", unsupported_service_management_message("uninstall")); + } + let mut context = service_management_context(layout)?; + context.plist_changed = false; + let before = launchctl_query(&context)?; + if before.loaded { + launchctl_bootout(&context)?; + context.plist_changed = true; + } + + let removed = remove_launch_agent_plist(&context.launch_agent_path)?; + context.plist_changed |= removed; + + Ok(context.into_report( + "uninstall", + false, + Some(false), + Some(LaunchctlServiceStatus { + loaded: false, + state: None, + pid: None, + last_exit_status: None, + detail: None, + }), + Some("LaunchAgent unloaded and removed".to_owned()), + )) +} + +pub fn service_status(layout: &FsLayout) -> Result { + if !service_management_supported() { + return Ok(unsupported_service_management_report("status")); + } + let context = service_management_context(layout)?; + let installed = context.launch_agent_path.is_file(); + let launchctl = launchctl_query(&context)?; + Ok(service_status_report(context, installed, launchctl)) +} + +pub fn write_service_management_human(report: &ServiceManagementReport) -> Result<()> { + let stdout = io::stdout(); + let mut output = stdout.lock(); + writeln!( + output, + "cbth service: {}", + if report.installed { + "installed" + } else { + "not installed" + } + )?; + writeln!(output, " manager: {}", report.manager)?; + writeln!(output, " label: {}", report.label)?; + if let Some(path) = &report.launch_agent_path { + writeln!(output, " plist: {path}")?; + } + if let Some(target) = &report.target { + writeln!(output, " launchd target: {target}")?; + } + if let Some(loaded) = report.loaded { + let mut loaded_line = if loaded { + "yes".to_owned() + } else { + "no".to_owned() + }; + if let Some(launchctl) = &report.launchctl { + if let Some(state) = &launchctl.state { + loaded_line.push_str(&format!(" state={state}")); + } + if let Some(pid) = launchctl.pid { + loaded_line.push_str(&format!(" pid={pid}")); + } + } + writeln!(output, " loaded: {loaded_line}")?; + } else { + writeln!(output, " loaded: unsupported")?; + } + if !report.program_arguments.is_empty() { + writeln!(output, " program: {}", report.program_arguments.join(" "))?; + } + if let Some(message) = &report.message { + writeln!(output, " message: {message}")?; + } + Ok(()) +} + +#[derive(Debug)] +struct ServiceManagementContext { + platform: String, + manager: String, + label: String, + domain: String, + target: String, + launch_agent_path: PathBuf, + program_arguments: Vec, + plist: Vec, + plist_changed: bool, +} + +impl ServiceManagementContext { + fn into_report( + self, + action: &str, + installed: bool, + loaded: Option, + launchctl: Option, + message: Option, + ) -> ServiceManagementReport { + ServiceManagementReport { + action: action.to_owned(), + platform: self.platform, + manager: self.manager, + supported: true, + label: self.label, + domain: Some(self.domain), + target: Some(self.target), + launch_agent_path: Some(self.launch_agent_path.display().to_string()), + installed, + loaded, + changed: self.plist_changed, + program_arguments: self.program_arguments, + launchctl, + message, + } + } +} + +fn service_management_supported() -> bool { + cfg!(target_os = "macos") +} + +fn unsupported_service_management_message(action: &str) -> String { + format!( + "cbth service {action} is only implemented for macOS LaunchAgent; Linux user systemd support is the documented future shape" + ) +} + +fn unsupported_service_management_report(action: &str) -> ServiceManagementReport { + let manager = if cfg!(target_os = "linux") { + "linux_user_systemd_future" + } else { + "unsupported" + }; + ServiceManagementReport { + action: action.to_owned(), + platform: env::consts::OS.to_owned(), + manager: manager.to_owned(), + supported: false, + label: SERVICE_LAUNCH_AGENT_LABEL.to_owned(), + domain: None, + target: None, + launch_agent_path: None, + installed: false, + loaded: None, + changed: false, + program_arguments: Vec::new(), + launchctl: None, + message: Some(unsupported_service_management_message(action)), + } +} + +fn service_status_report( + mut context: ServiceManagementContext, + installed: bool, + launchctl: LaunchctlServiceStatus, +) -> ServiceManagementReport { + context.plist_changed = false; + context.into_report( + "status", + installed, + Some(launchctl.loaded), + Some(launchctl), + None, + ) +} + +fn service_management_context(layout: &FsLayout) -> Result { + let launch_agents_dir = default_launch_agents_dir()?; + let launch_agent_path = launch_agent_path_in(&launch_agents_dir); + let program_path = env::current_exe().context("resolve current cbth executable")?; + let program_arguments = launch_agent_program_arguments(&program_path, layout.home_dir())?; + let (stdout_log, stderr_log) = launch_agent_log_paths(&layout.run_dir())?; + let plist = launch_agent_plist( + SERVICE_LAUNCH_AGENT_LABEL, + &program_arguments, + &stdout_log, + &stderr_log, + ) + .into_bytes(); + let plist_changed = fs::read(&launch_agent_path) + .map(|existing| existing != plist) + .unwrap_or(true); + let uid = current_uid(); + let domain = launchd_domain(uid); + let target = launchd_target(&domain, SERVICE_LAUNCH_AGENT_LABEL); + + Ok(ServiceManagementContext { + platform: "macos".to_owned(), + manager: "macos_launch_agent".to_owned(), + label: SERVICE_LAUNCH_AGENT_LABEL.to_owned(), + domain, + target, + launch_agent_path, + program_arguments, + plist, + plist_changed, + }) +} + +fn default_launch_agents_dir() -> Result { + let home = env::var_os("HOME") + .map(PathBuf::from) + .context("HOME is unavailable; cannot locate ~/Library/LaunchAgents")?; + Ok(home.join("Library").join("LaunchAgents")) +} + +fn launch_agent_path_in(launch_agents_dir: &Path) -> PathBuf { + launch_agents_dir.join(SERVICE_LAUNCH_AGENT_PLIST_NAME) +} + +fn launch_agent_program_arguments(program_path: &Path, cbth_home: &Path) -> Result> { + Ok(vec![ + path_to_launchd_arg(&absolute_path(program_path)?)?, + "--home".to_owned(), + path_to_launchd_arg(&absolute_path(cbth_home)?)?, + "service".to_owned(), + "run".to_owned(), + ]) +} + +fn launch_agent_log_paths(run_dir: &Path) -> Result<(PathBuf, PathBuf)> { + let run_dir = absolute_path(run_dir)?; + Ok(( + run_dir.join(SERVICE_LAUNCH_AGENT_STDOUT_LOG), + run_dir.join(SERVICE_LAUNCH_AGENT_STDERR_LOG), + )) +} + +fn launch_agent_plist( + label: &str, + program_arguments: &[String], + stdout_log: &Path, + stderr_log: &Path, +) -> String { + let arguments = program_arguments + .iter() + .map(|argument| format!(" {}\n", plist_escape(argument))) + .collect::(); + format!( + r#" + + + + Label + {label} + ProgramArguments + +{arguments} + RunAtLoad + + KeepAlive + + StandardOutPath + {stdout_log} + StandardErrorPath + {stderr_log} + ProcessType + Background + + +"#, + label = plist_escape(label), + arguments = arguments, + stdout_log = plist_escape(&stdout_log.display().to_string()), + stderr_log = plist_escape(&stderr_log.display().to_string()), + ) +} + +fn write_launch_agent_plist_if_changed(context: &ServiceManagementContext) -> Result<()> { + if !context.plist_changed { + return Ok(()); + } + if let Some(parent) = context.launch_agent_path.parent() { + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + let parent = context + .launch_agent_path + .parent() + .with_context(|| format!("path {} has no parent", context.launch_agent_path.display()))?; + let file_name = context + .launch_agent_path + .file_name() + .and_then(|value| value.to_str()) + .with_context(|| { + format!( + "path {} has no valid file name", + context.launch_agent_path.display() + ) + })?; + let tmp = parent.join(format!(".{file_name}.{}.tmp", uuid::Uuid::now_v7())); + let write_result = (|| -> Result<()> { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o644) + .open(&tmp) + .with_context(|| format!("create {}", tmp.display()))?; + file.write_all(&context.plist) + .with_context(|| format!("write {}", tmp.display()))?; + file.sync_all() + .with_context(|| format!("sync {}", tmp.display()))?; + fs::set_permissions(&tmp, fs::Permissions::from_mode(0o644)) + .with_context(|| format!("chmod {}", tmp.display()))?; + Ok(()) + })(); + if let Err(error) = write_result { + let _ = fs::remove_file(&tmp); + return Err(error); + } + if let Err(error) = fs::rename(&tmp, &context.launch_agent_path) { + let _ = fs::remove_file(&tmp); + return Err(error).with_context(|| { + format!( + "rename {} to {}", + tmp.display(), + context.launch_agent_path.display() + ) + }); + } + sync_dir(parent)?; + Ok(()) +} + +fn remove_launch_agent_plist(path: &Path) -> Result { + let Some(parent) = path.parent() else { + bail!("path {} has no parent", path.display()); + }; + match fs::remove_file(path) { + Ok(()) => { + sync_dir(parent)?; + Ok(true) + } + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error).with_context(|| format!("remove {}", path.display())), + } +} + +fn launchctl_query(context: &ServiceManagementContext) -> Result { + let output = Command::new("launchctl") + .arg("print") + .arg(&context.target) + .output() + .with_context(|| format!("run launchctl print {}", context.target))?; + if output.status.success() { + return Ok(parse_launchctl_print(&String::from_utf8_lossy( + &output.stdout, + ))); + } + let detail = launchctl_detail(&output); + if launchctl_output_is_not_found(&detail) { + return Ok(LaunchctlServiceStatus { + loaded: false, + state: None, + pid: None, + last_exit_status: None, + detail: Some(detail), + }); + } + bail!("launchctl print {} failed: {}", context.target, detail) +} + +fn launchctl_bootstrap(context: &ServiceManagementContext) -> Result<()> { + let output = Command::new("launchctl") + .arg("bootstrap") + .arg(&context.domain) + .arg(&context.launch_agent_path) + .output() + .with_context(|| { + format!( + "run launchctl bootstrap {} {}", + context.domain, + context.launch_agent_path.display() + ) + })?; + if output.status.success() { + Ok(()) + } else { + bail!( + "launchctl bootstrap {} {} failed: {}", + context.domain, + context.launch_agent_path.display(), + launchctl_detail(&output) + ) + } +} + +fn launchctl_enable(context: &ServiceManagementContext) -> Result { + let [subcommand, target] = launchctl_enable_arguments(&context.target); + let output = Command::new("launchctl") + .arg(subcommand) + .arg(target) + .output() + .with_context(|| format!("run launchctl enable {}", context.target))?; + if output.status.success() { + return Ok(true); + } + let detail = launchctl_detail(&output); + if launchctl_output_is_not_found(&detail) { + return Ok(false); + } + bail!("launchctl enable {} failed: {}", context.target, detail) +} + +fn launchctl_enable_arguments(target: &str) -> [&str; 2] { + ["enable", target] +} + +fn launchctl_bootout(context: &ServiceManagementContext) -> Result<()> { + let output = Command::new("launchctl") + .arg("bootout") + .arg(&context.target) + .output() + .with_context(|| format!("run launchctl bootout {}", context.target))?; + if output.status.success() { + return Ok(()); + } + let detail = launchctl_detail(&output); + if launchctl_output_is_not_found(&detail) { + return Ok(()); + } + bail!("launchctl bootout {} failed: {}", context.target, detail) +} + +fn parse_launchctl_print(output: &str) -> LaunchctlServiceStatus { + let mut state = None; + let mut pid = None; + let mut last_exit_status = None; + + for line in output.lines().map(str::trim) { + if let Some(value) = line.strip_prefix("state = ") { + state = Some(value.to_owned()); + } else if let Some(value) = line.strip_prefix("pid = ") { + pid = value.parse::().ok(); + } else if let Some(value) = line.strip_prefix("last exit code = ") { + last_exit_status = Some(value.to_owned()); + } else if let Some(value) = line.strip_prefix("last exit status = ") { + last_exit_status = Some(value.to_owned()); + } + } + + LaunchctlServiceStatus { + loaded: true, + state, + pid, + last_exit_status, + detail: None, + } +} + +fn launchctl_output_is_not_found(detail: &str) -> bool { + let lower = detail.to_ascii_lowercase(); + lower.contains("could not find service") + || lower.contains("could not find domain") + || lower.contains("no such process") + || lower.contains("service is not loaded") + || lower.contains("not found") +} + +fn launchctl_detail(output: &std::process::Output) -> String { + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + match (stdout.is_empty(), stderr.is_empty()) { + (true, true) => output.status.to_string(), + (false, true) => stdout, + (true, false) => stderr, + (false, false) => format!("{stdout}; {stderr}"), + } +} + +fn absolute_path(path: &Path) -> Result { + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + Ok(env::current_dir() + .context("read current directory")? + .join(path)) + } +} + +fn path_to_launchd_arg(path: &Path) -> Result { + path.to_str() + .map(ToOwned::to_owned) + .with_context(|| format!("path is not valid UTF-8: {}", path.display())) +} + +fn plist_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn current_uid() -> u32 { + // SAFETY: getuid has no preconditions and cannot fail. + unsafe { libc::getuid() as u32 } +} + +fn launchd_domain(uid: u32) -> String { + format!("gui/{uid}") +} + +fn launchd_target(domain: &str, label: &str) -> String { + format!("{domain}/{label}") +} + struct ServiceSharedState { layout: FsLayout, plugins: HashMap, @@ -4904,6 +5508,156 @@ mod tests { } } + #[test] + fn launch_agent_program_arguments_start_cbth_service_run_with_explicit_home() { + let args = launch_agent_program_arguments( + Path::new("/opt/cbth/bin/cbth"), + Path::new("/Users/alice/.cbth"), + ) + .expect("program arguments"); + + assert_eq!( + args, + vec![ + "/opt/cbth/bin/cbth", + "--home", + "/Users/alice/.cbth", + "service", + "run" + ] + ); + } + + #[test] + fn launch_agent_log_paths_are_absolute_for_relative_home() { + let (stdout_log, stderr_log) = + launch_agent_log_paths(Path::new("relative-cbth-home/run")).expect("log paths"); + let cwd = env::current_dir().expect("current dir"); + + assert_eq!( + stdout_log, + cwd.join("relative-cbth-home") + .join("run") + .join(SERVICE_LAUNCH_AGENT_STDOUT_LOG) + ); + assert_eq!( + stderr_log, + cwd.join("relative-cbth-home") + .join("run") + .join(SERVICE_LAUNCH_AGENT_STDERR_LOG) + ); + } + + #[test] + fn launch_agent_plist_escapes_paths_and_never_starts_plugin_directly() { + let args = vec![ + "/tmp/cbth&bin".to_owned(), + "--home".to_owned(), + "/tmp/cbth".to_owned(), + "service".to_owned(), + "run".to_owned(), + ]; + let plist = launch_agent_plist( + "com.example.cbth", + &args, + Path::new("/tmp/cbth/out&service.log"), + Path::new("/tmp/cbth/err.log"), + ); + + assert!(plist.contains("ProgramArguments")); + assert!(plist.contains("/tmp/cbth&bin")); + assert!(plist.contains("/tmp/cbth<home>")); + assert!(plist.contains("service")); + assert!(plist.contains("run")); + assert!(plist.contains("KeepAlive")); + assert!(plist.contains("/tmp/cbth/out&service.log")); + assert!(plist.contains("/tmp/cbth/err<service>.log")); + assert!(!plist.contains("webex")); + } + + #[test] + fn launch_agent_path_uses_user_launch_agents_dir() { + let path = launch_agent_path_in(Path::new("/Users/alice/Library/LaunchAgents")); + assert_eq!( + path, + PathBuf::from("/Users/alice/Library/LaunchAgents") + .join(SERVICE_LAUNCH_AGENT_PLIST_NAME) + ); + } + + #[test] + fn launchctl_enable_arguments_target_launch_agent_label() { + assert_eq!( + launchctl_enable_arguments("gui/501/com.joeyteng.cbth.service"), + ["enable", "gui/501/com.joeyteng.cbth.service"] + ); + } + + #[test] + fn service_status_report_is_read_only_when_plist_would_change() { + let context = ServiceManagementContext { + platform: "macos".to_owned(), + manager: "macos_launch_agent".to_owned(), + label: SERVICE_LAUNCH_AGENT_LABEL.to_owned(), + domain: "gui/501".to_owned(), + target: "gui/501/com.joeyteng.cbth.service".to_owned(), + launch_agent_path: PathBuf::from( + "/Users/alice/Library/LaunchAgents/com.joeyteng.cbth.service.plist", + ), + program_arguments: vec!["cbth".to_owned(), "service".to_owned(), "run".to_owned()], + plist: b"".to_vec(), + plist_changed: true, + }; + let report = service_status_report( + context, + true, + LaunchctlServiceStatus { + loaded: false, + state: None, + pid: None, + last_exit_status: None, + detail: Some("Could not find service".to_owned()), + }, + ); + + assert_eq!(report.action, "status"); + assert!(report.installed); + assert_eq!(report.loaded, Some(false)); + assert!(!report.changed); + } + + #[test] + fn parse_launchctl_print_extracts_running_status() { + let status = parse_launchctl_print( + r#" +gui/501/com.joeyteng.cbth.service = { + active count = 1 + path = /Users/alice/Library/LaunchAgents/com.joeyteng.cbth.service.plist + state = running + pid = 12345 + last exit status = 0 +} +"#, + ); + + assert!(status.loaded); + assert_eq!(status.state.as_deref(), Some("running")); + assert_eq!(status.pid, Some(12345)); + assert_eq!(status.last_exit_status.as_deref(), Some("0")); + } + + #[test] + fn launchctl_not_found_detection_accepts_common_messages() { + assert!(launchctl_output_is_not_found( + "Could not find service \"com.joeyteng.cbth.service\" in domain for user gui: 501" + )); + assert!(launchctl_output_is_not_found( + "Could not find domain for user gui: 501" + )); + assert!(launchctl_output_is_not_found("No such process")); + assert!(!launchctl_output_is_not_found("permission denied")); + } + #[derive(Default)] struct FakePluginAppServerLeaseBroker { ensure_requests: Vec<(PluginConnectionIdentity, PluginAppServerEnsureRequest)>, diff --git a/tests/cli_help.rs b/tests/cli_help.rs index bdc5ce0..f4e80ef 100644 --- a/tests/cli_help.rs +++ b/tests/cli_help.rs @@ -34,6 +34,47 @@ fn plugin_status_help_describes_optional_name_and_json() { assert!(stdout.contains("Inspect configured host-level plugin supervisor state")); } +#[test] +fn service_help_lists_launch_agent_management_commands() { + let stdout = help(&["service", "--help"]); + assert!(stdout.contains("install")); + assert!(stdout.contains("uninstall")); + assert!(stdout.contains("status")); + assert!(stdout.contains("run")); +} + +#[test] +fn service_status_help_describes_json_output() { + let stdout = help(&["service", "status", "--help"]); + assert!(stdout.contains("--json")); + assert!(stdout.contains("Inspect cbth service autostart state")); +} + +#[test] +fn service_status_json_does_not_autostart_service() { + let home = tempfile::tempdir().expect("temp home"); + let user_home = tempfile::tempdir().expect("temp user home"); + let output = Command::new(env!("CARGO_BIN_EXE_cbth")) + .env("HOME", user_home.path()) + .arg("--home") + .arg(home.path()) + .arg("service") + .arg("status") + .arg("--json") + .output() + .expect("run service status json"); + assert!( + output.status.success(), + "service status failed\nstatus: {}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("service_status")); + assert!(!home.path().join("run/plugin-rpc.sock").exists()); +} + #[test] fn plugin_status_human_does_not_autostart_service() { let home = tempfile::tempdir().expect("temp home");