Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions crates/slingshot-agent/src/awake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<String> {
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 {
Expand Down Expand Up @@ -107,6 +154,29 @@ fn start(mut tool: Tool) -> Option<Awake> {
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"));
Expand Down
82 changes: 80 additions & 2 deletions crates/slingshot-agent/src/boot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -21,6 +23,9 @@ pub struct Plan {
commands: Vec<Vec<String>>,
/// Lets a Linux user service start before anyone logs in. `None` when already on.
linger: Option<Vec<String>>,
/// 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<String>,
/// "at boot" or "at login", for the question and the result.
pub when: &'static str,
logs: String,
Expand Down Expand Up @@ -70,6 +75,7 @@ pub fn plan(root: &Path, name: &str, user: &str) -> anyhow::Result<Plan> {
&file.to_string_lossy(),
])],
linger: None,
sleep_rule: None,
when: "at login",
logs: home_path(&log),
file,
Expand All @@ -85,6 +91,10 @@ pub fn plan(root: &Path, name: &str, user: &str) -> anyhow::Result<Plan> {
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,
Expand All @@ -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
Expand All @@ -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<i32> {
Expand All @@ -149,6 +223,9 @@ pub async fn remove() -> anyhow::Result<i32> {
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"
Expand All @@ -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<()> {
Expand Down
11 changes: 10 additions & 1 deletion crates/slingshot-agent/src/uninstall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
9 changes: 7 additions & 2 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<project>`, 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. |
Expand All @@ -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`.
Expand Down Expand Up @@ -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.
4 changes: 3 additions & 1 deletion docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
| `Slingshot is already running here as archbox, not <name>` | `--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 <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. |

Expand All @@ -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: <reason>`, 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 <user>` 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`. |
Expand Down
1 change: 1 addition & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading