diff --git a/crates/slingshot-agent/src/awake.rs b/crates/slingshot-agent/src/awake.rs index f4a792c..0bbbc98 100644 --- a/crates/slingshot-agent/src/awake.rs +++ b/crates/slingshot-agent/src/awake.rs @@ -2,6 +2,7 @@ //! Agent cannot be reached from anywhere. Wraps the system's own tool, and ties its lock to //! this process so the lock ends with `start`, even when `start` is killed outright. +use std::path::Path; use std::process::{Child, ChildStdin, Command, Stdio}; use std::time::Duration; @@ -34,6 +35,52 @@ fn masked(stdout: &[u8]) -> bool { String::from_utf8_lossy(stdout).trim().starts_with("masked") } +/// The polkit rule that lets this account hold the sleep lock with nobody logged in, as a +/// boot service must. Without it, polkit asks for an admin password outside a session at +/// the machine, so the lock is refused and an idle login screen can suspend the Agent. +pub const SLEEP_RULE: &str = "/etc/polkit-1/rules.d/50-slingshot.rules"; + +/// Saved in the Agent's folder once the rule is written, since the rules folder is +/// readable only by root. +const SLEEP_RULE_MARK: &str = "sleep-rule"; + +pub fn rule_written(root: &Path) -> bool { + root.join(SLEEP_RULE_MARK).exists() +} + +pub fn mark_rule(root: &Path, written: bool) -> anyhow::Result<()> { + let mark = root.join(SLEEP_RULE_MARK); + match written { + true => slingshot_core::storage::write_bytes(&mark, b""), + false => match std::fs::remove_file(&mark) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()), + _ => Ok(()), + }, + } +} + +/// The rule's text for `user`, or `None` for a name that is not a plain account name, since +/// it is written into the rule as a string. +pub fn sleep_rule(user: &str) -> Option { + let plain = !user.is_empty() + && user + .chars() + .all(|c| c.is_ascii_alphanumeric() || "_.-".contains(c)); + plain.then(|| { + format!( + "// Written by Slingshot: lets {user} keep this machine awake while Slingshot runs,\n\ + // even with nobody logged in. slingshot start --remove deletes it.\n\ + polkit.addRule(function(action, subject) {{\n\ + \x20 if ((action.id == \"org.freedesktop.login1.inhibit-block-sleep\" ||\n\ + \x20 action.id == \"org.freedesktop.login1.inhibit-block-idle\") &&\n\ + \x20 subject.user == \"{user}\") {{\n\ + \x20 return polkit.Result.YES;\n\ + \x20 }}\n\ + }});\n" + ) + }) +} + /// A tool that keeps the system awake. `lifeline` means it runs a command that must read /// from this process to stay alive, which is how it learns that this process has ended. struct Tool { @@ -107,6 +154,29 @@ fn start(mut tool: Tool) -> Option { mod tests { use super::*; + #[test] + fn the_sleep_rule_allows_only_this_account_both_locks() { + let rule = sleep_rule("ado").unwrap(); + assert!(rule.contains("subject.user == \"ado\""), "{rule}"); + assert!(rule.contains("org.freedesktop.login1.inhibit-block-sleep")); + assert!(rule.contains("org.freedesktop.login1.inhibit-block-idle")); + assert!(rule.contains(" if ((action.id"), "{rule}"); + assert_eq!(rule.matches('{').count(), rule.matches('}').count()); + assert!(sleep_rule("a\"; evil").is_none()); + assert!(sleep_rule("").is_none()); + } + + #[test] + fn the_rule_mark_is_saved_and_cleared() { + let root = crate::testing::Root::new(); + assert!(!rule_written(&root.0)); + mark_rule(&root.0, true).unwrap(); + assert!(rule_written(&root.0)); + mark_rule(&root.0, false).unwrap(); + mark_rule(&root.0, false).unwrap(); + assert!(!rule_written(&root.0)); + } + #[test] fn only_a_masked_target_means_sleep_is_off() { assert!(masked(b"masked\n")); diff --git a/crates/slingshot-agent/src/boot.rs b/crates/slingshot-agent/src/boot.rs index 61b2e1d..aaf5df2 100644 --- a/crates/slingshot-agent/src/boot.rs +++ b/crates/slingshot-agent/src/boot.rs @@ -2,11 +2,13 @@ //! service on Linux, with linger so it starts before anyone logs in, and a launchd agent on //! macOS, which starts at login. Either one runs `slingshot internal-daemon`. +use crate::{awake, service}; use anyhow::{Context, bail}; use slingshot_core::presentation::{self, home_path}; use slingshot_core::storage; +use std::io::Write; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Command, Stdio}; const UNIT: &str = "slingshot.service"; const LABEL: &str = "dev.slingshot.agent"; @@ -21,6 +23,9 @@ pub struct Plan { commands: Vec>, /// Lets a Linux user service start before anyone logs in. `None` when already on. linger: Option>, + /// The polkit rule a Linux service needs to keep a machine that can sleep awake with + /// nobody logged in. `None` when sleep is off or the rule is already written. + sleep_rule: Option, /// "at boot" or "at login", for the question and the result. pub when: &'static str, logs: String, @@ -70,6 +75,7 @@ pub fn plan(root: &Path, name: &str, user: &str) -> anyhow::Result { &file.to_string_lossy(), ])], linger: None, + sleep_rule: None, when: "at login", logs: home_path(&log), file, @@ -85,6 +91,10 @@ pub fn plan(root: &Path, name: &str, user: &str) -> anyhow::Result { words(&["systemctl", "--user", "enable", "--now", UNIT]), ], linger: (!lingering(user)).then(|| words(&["loginctl", "enable-linger", user])), + sleep_rule: match awake::sleep_off() || awake::rule_written(root) { + true => None, + false => awake::sleep_rule(user), + }, when: "at boot", logs: format!("journalctl --user -u {UNIT}"), file, @@ -109,6 +119,15 @@ pub fn show(plan: &Plan) { for command in plan.commands.iter().chain(&plan.linger) { presentation::detail("Runs", shell_words::join(command)); } + if plan.sleep_rule.is_some() { + presentation::detail( + "Writes", + format!( + "{}, with sudo, so this machine stays awake with nobody logged in", + awake::SLEEP_RULE + ), + ); + } } /// Write the service file and start the service. A refused linger only means the service @@ -134,9 +153,64 @@ pub fn install(plan: &Plan) -> anyhow::Result<()> { shell_words::join(linger) )); } + if let Some(rule) = &plan.sleep_rule { + match write_sleep_rule(rule) { + Ok(()) => { + awake::mark_rule(&service::root()?, true)?; + presentation::success("This machine stays awake with nobody logged in"); + } + Err(error) => presentation::warning(format!( + "{error:#}. This machine may sleep with nobody logged in. Run slingshot start --boot in a terminal to try again" + )), + } + } Ok(()) } +/// Write the polkit rule with `sudo tee`, which asks for the password in this terminal. +fn write_sleep_rule(rule: &str) -> anyhow::Result<()> { + let mut child = Command::new("sudo") + .args(["tee", awake::SLEEP_RULE]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .spawn() + .context("Could not start sudo")?; + child + .stdin + .take() + .context("Could not reach sudo")? + .write_all(rule.as_bytes())?; + anyhow::ensure!( + child.wait()?.success(), + "Could not write {}", + awake::SLEEP_RULE + ); + Ok(()) +} + +/// Delete the polkit rule, if Slingshot wrote one. A failure leaves the exact command. +fn remove_sleep_rule() -> anyhow::Result<()> { + let root = service::root()?; + if !awake::rule_written(&root) { + return Ok(()); + } + let removed = Command::new("sudo") + .args(["rm", "-f", awake::SLEEP_RULE]) + .status() + .is_ok_and(|status| status.success()); + match removed { + true => awake::mark_rule(&root, false), + false => { + presentation::warning(format!( + "Could not delete {}. Delete it with sudo rm {}", + awake::SLEEP_RULE, + awake::SLEEP_RULE + )); + Ok(()) + } + } +} + /// `slingshot start --remove`: show what will happen, ask, then stop the service and delete /// its file. Linger stays on, because other services of this account may rely on it. pub async fn remove() -> anyhow::Result { @@ -149,6 +223,9 @@ pub async fn remove() -> anyhow::Result { eprintln!("\n This stops Slingshot and stops it starting by itself:"); presentation::detail("Runs", shell_words::join(&command)); presentation::detail("Deletes", home_path(&file)); + if awake::rule_written(&service::root()?) { + presentation::detail("Deletes", format!("{}, with sudo", awake::SLEEP_RULE)); + } anyhow::ensure!( crate::interactive(), "Run slingshot start --remove in a terminal to confirm" @@ -168,7 +245,8 @@ pub fn remove_service() -> anyhow::Result<()> { presentation::warning(format!("{error:#}")); } std::fs::remove_file(&file) - .with_context(|| format!("Could not delete {}. Delete it by hand", file.display())) + .with_context(|| format!("Could not delete {}. Delete it by hand", file.display()))?; + remove_sleep_rule() } fn run(command: &[String]) -> anyhow::Result<()> { diff --git a/crates/slingshot-agent/src/uninstall.rs b/crates/slingshot-agent/src/uninstall.rs index adaf8ac..b2136ec 100644 --- a/crates/slingshot-agent/src/uninstall.rs +++ b/crates/slingshot-agent/src/uninstall.rs @@ -63,9 +63,18 @@ impl Found { pub fn listing(&self) -> Vec<(&'static str, String)> { let mut lines = Vec::new(); if let Some(file) = &self.service { + let rule = match &self.data { + Some((root, _, _)) if crate::awake::rule_written(root) => { + format!(" and {}, with sudo", crate::awake::SLEEP_RULE) + } + _ => String::new(), + }; lines.push(( "Service", - format!("stops it, and removes {}", presentation::home_path(file)), + format!( + "stops it, and removes {}{rule}", + presentation::home_path(file) + ), )); } if !self.active.is_empty() { diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8b91ce9..09c65ce 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -62,7 +62,7 @@ | Notifications | Finished, failed, and interrupted runs; the Agent going offline or returning; low memory, low disk, or a hot GPU. Shown with the Slingshot icon. | | Sessions | `attach` from anywhere. Syncs first, and pulls edits back on leaving. Login shell, full color, `UTF-8`, mouse scrolling, a colored bar, `~/Slingshot/`, and the user folders on PATH. Works from terminals the Agent may not know, such as Kitty. | | Tools | `link` ends by offering the Client's tools on the Agent, including OpenCode, and Ollama when the Agent has a GPU, then signs in to Claude Code and Codex. `slingshot tools` repeats it, offers sign in when signed out, and lists `sudo` steps for an admin on accounts without it. | -| Starting | A systemd user service with linger, or a launchd agent, offered on the first `slingshot start`. `--boot` and `--remove` manage it. Moves to the next free port when the pairing port is taken. Warns when Docker is unusable, and says when sleep is off. | +| Starting | A systemd user service with linger, or a launchd agent, offered on the first `slingshot start`. On Linux, a polkit rule keeps an Agent that can sleep awake with nobody logged in. `--boot` and `--remove` manage it. Moves to the next free port when the pairing port is taken. Warns when Docker is unusable, and says when sleep is off. | | Sync | `--overwrite` keeps one side of a conflict, with a backup. Pulls refuse names that differ only in case. A Python `.venv` works on the Agent. | | Output | Problems on one line with dimmed fixes. `link` and `tools` in sections. Stopped runs are not failed, and `ps --all` shows `Lost connection`. Version errors name the older machine. | | Removal | `slingshot uninstall` lists everything on a Client or Agent, asks once with No as the default, and keeps the owner's own files and keys. | @@ -85,7 +85,6 @@ ## Known Limitations -* On Linux, a boot service with nobody logged in cannot block sleep, so an Agent that can sleep may suspend after about 15 minutes idle ([#68](https://github.com/ado11231/ss/issues/68)). * Starting by itself on a macOS Agent is untested. * After the Agent loses power or crashes, the first iroh connection can wait about 20 seconds for the old one to time out. * Edits in a session whose connection dropped stay on the Agent until `slingshot sync --pull`. @@ -186,3 +185,9 @@ 1. `slingshot projects` listed three projects on archbox with their sizes, last sync, and edits waiting. 2. `clean` on Slingshot itself freed 3.9 GiB, and the next `cargo build` made it again in 28 seconds. 3. A throwaway project showed 2 edits waiting. `remove` listed them, kept everything on Enter, and on `y` deleted the copy and its `~/Slingshot` link. + +### Sleep Rule: October 16, 2026 + +1. With sleep turned back on, `slingshot start --boot` showed the polkit rule, wrote it with `sudo`, and printed `This machine stays awake with nobody logged in`. +2. After `sudo reboot`, with only the login screen on archbox, Slingshot held the sleep lock from the first minute. +3. 15 minutes after boot, GNOME tried to suspend and was refused by the lock. archbox was still reachable after 30 minutes. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 2d77230..d4060fa 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -30,7 +30,7 @@ | `Slingshot is already running here as archbox, not ` | `--name` differs from the running Agent's name, which linked Clients use. | Run `slingshot start` without `--name`. | | `! Port 7433 is in use, perhaps by another slingshot start, so this one uses 7434` | Another program or account has the pairing port, so the next free one is used. | Nothing. The printed link line has the new port. | | `Ports 7433 to 7442 are all in use` | All ten ports tried were taken. | Pick another with `slingshot start --port `. | -| `Could not stop this machine from sleeping` | A warning. A sleeping Agent cannot be reached. On Linux, a boot service with nobody logged in cannot block sleep, and GNOME's login screen suspends after about 15 minutes. Issue #68 tracks a fix. | Turn sleep off: `sudo systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target`. Slingshot then prints `Sleep is turned off on this machine`. | +| `Could not stop this machine from sleeping` | A warning. A sleeping Agent cannot be reached. On Linux, a boot service with nobody logged in needs the polkit rule that `slingshot start --boot` writes. | Run `slingshot start --boot` in a terminal. Or turn sleep off: `sudo systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target`. | | `No iroh relay answered` | A warning. Other networks cannot reach the Agent yet. The same network still works, and Slingshot keeps trying. | Check the Agent's internet connection. | | No message: unreachable after a restart | Slingshot does not start by itself on this Agent. | Run `slingshot start --boot` on the Agent. | @@ -44,6 +44,8 @@ | `Slingshot was set up to start at boot but did not start` | The service did not answer within 15 seconds. | Fix what the logs report, then `slingshot start --boot`. | | `systemctl --user enable --now slingshot.service failed: `, or the same for `launchctl` | The service manager refused a step. | Fix the reason given, then `slingshot start --boot`. | | `! Slingshot starts when you log in, not at boot` | Linux refused linger without an admin password. | Run the `sudo loginctl enable-linger ` command printed. | +| `Could not write /etc/polkit-1/rules.d/50-slingshot.rules. This machine may sleep with nobody logged in` | `sudo` was refused, or there was no terminal to type the password. | Run `slingshot start --boot` in a terminal. Without `sudo`, ask an admin, or turn sleep off. | +| `Could not delete /etc/polkit-1/rules.d/50-slingshot.rules` | `sudo` was refused while removing. | Run the `sudo rm` command printed. | | `Slingshot is already running for this account. Stop the other slingshot start, then run slingshot start --boot again` | A `slingshot start` in another terminal holds the Agent. | Ctrl C there, then `slingshot start --boot`. | | `Run slingshot start --remove in a terminal to confirm` | Removing asks first, and there was no terminal. | Run it in a terminal on the Agent. | | `Starting at boot works on Linux and macOS`, or `A line break cannot go in a systemd unit` | No supported service manager, or the Agent name or program path has a line break. | Run `slingshot start` by hand, or rename the Agent with `--name`. | diff --git a/docs/USAGE.md b/docs/USAGE.md index ac82207..c8998c3 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -66,6 +66,7 @@ slingshot start * The service prints no code. Run `slingshot start` and press Enter for one. Ctrl C leaves the service running. * The service keeps the PATH of the terminal it was set up from. * If linger is refused, it starts at login, and Slingshot prints the `sudo` fix. +* On a Linux Agent that can sleep, it also writes `/etc/polkit-1/rules.d/50-slingshot.rules` with `sudo`. The rule lets your account keep the machine awake with nobody logged in. `--remove` deletes it. ### Link The Client