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
298 changes: 298 additions & 0 deletions libs/cua-driver-rs/crates/cua-driver/src/autostart.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
//! `cua-driver autostart {enable|disable|status|kick}` — register / inspect /
//! trigger the platform-native auto-start mechanism so `cua-driver serve`
//! comes up on every interactive logon without the user pasting a
//! startup one-liner.
//!
//! ## Platform mapping
//!
//! - **Windows**: Scheduled Task `cua-driver-serve` registered with
//! `LogonType: Interactive` so it lands in a Session 1+ logon (never
//! Session 0). Equivalent to what `scripts/install.ps1 -AutoStart`
//! does — the install script can call out to this subcommand to
//! keep the registration logic in one place.
//! - **macOS / Linux**: not implemented yet. Returns an error pointing
//! the user at the manual recipe (`launchctl` / `systemctl --user`).
//! `scripts/install-local.sh --autostart` covers the manual path
//! today.
//!
//! ## Why shell out (Windows)
//!
//! The Task Scheduler 2.0 COM surface (`ITaskService`, `ITaskDefinition`,
//! `ITaskFolder`, `IPrincipal`, ...) is ~10 nested COM-wrapper calls in
//! Rust before you've even configured the principal, with multiple BSTR
//! marshalling steps and a lot of "this method takes a VARIANT, that
//! one takes a BSTR" footguns. Shelling out to PowerShell's
//! `Register-ScheduledTask` cmdlet — which itself uses Task Scheduler
//! 2.0 under the hood — gets us identical behavior in 5 lines and stays
//! exactly in lock-step with what `scripts/install.ps1` does (literally
//! the same command). When `install.ps1` evolves, this code follows it
//! for free.

use anyhow::{anyhow, Result};

/// Canonical task / unit name. Used by every platform.
pub const TASK_NAME: &str = "cua-driver-serve";

/// Reported by `status`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
/// No autostart entry registered.
NotRegistered,
/// Entry registered but not currently running.
RegisteredIdle,
/// Entry registered AND a `cua-driver serve` process is live.
RegisteredRunning,
}

impl Status {
pub fn tag(self) -> &'static str {
match self {
Status::NotRegistered => "not-registered",
Status::RegisteredIdle => "registered (not running)",
Status::RegisteredRunning => "registered (running)",
}
}
}

// ── Public API ────────────────────────────────────────────────────────────

/// Register the platform-native autostart entry for `cua-driver serve`.
/// Idempotent: any existing entry with the same name is replaced.
pub fn enable() -> Result<()> {
let exe = current_exe_for_autostart()?;
platform::enable(&exe)
}

/// Remove the autostart entry. No-op if none is registered.
pub fn disable() -> Result<()> {
platform::disable()
}

/// Report whether the entry is registered and whether the daemon is running.
pub fn status() -> Result<Status> {
platform::status()
}

/// Run the autostart entry immediately without waiting for a fresh logon.
/// Errors if the entry isn't registered.
pub fn kick() -> Result<()> {
platform::kick()
}

/// Find the cua-driver executable to bake into the autostart entry.
/// Uses `std::env::current_exe`, canonicalised to its real path (resolves
/// junction / symlink chains so a versioned upgrade flipping `current`
/// stays transparent to the registered task). The resolved path is what
/// gets stored in the Scheduled Task / LaunchAgent / unit file.
fn current_exe_for_autostart() -> Result<String> {
let exe = std::env::current_exe()
.map_err(|e| anyhow!("could not resolve current executable: {e}"))?;
let canonical = std::fs::canonicalize(&exe).unwrap_or(exe);
let path = canonical.to_string_lossy().into_owned();
// On Windows, `canonicalize` returns a `\\?\C:\...` extended-length
// path. PowerShell + the Task Scheduler XML schema both handle it
// correctly, but it looks alarming in `schtasks /Query` output.
// Strip the prefix for readability — the unprefixed form is still
// valid as long as the path fits MAX_PATH (260 chars), which any
// realistic install will.
#[cfg(target_os = "windows")]
let path = path
.strip_prefix(r"\\?\")
.map(str::to_owned)
.unwrap_or(path);
Ok(path)
}

// ── Windows impl ──────────────────────────────────────────────────────────

#[cfg(target_os = "windows")]
mod platform {
use super::*;
use std::process::Command;

/// Inline PowerShell that mirrors `install.ps1::Register-CuaDriverAutostart`
/// exactly. Kept as a single one-liner so a quick `gh-blame` diff against
/// install.ps1 surfaces any divergence; the moment install.ps1 changes
/// shape, this script needs the same edit.
///
/// **Account-name format**: on domain-joined machines USERDOMAIN holds the
/// AD domain name (e.g. CORP) and the principal must be `CORP\username`.
/// On workgroup machines USERDOMAIN holds either the literal string
/// "WORKGROUP" or the COMPUTERNAME, and the principal must be
/// `COMPUTERNAME\username` — `WORKGROUP\username` errors with
/// "No mapping between account names and security IDs was done". The
/// $domain selector below picks USERDOMAIN when it's a real
/// (non-WORKGROUP, non-COMPUTERNAME) domain and falls back to
/// COMPUTERNAME otherwise, covering both shapes.
const REGISTER_PS: &str = r#"
$ErrorActionPreference = 'Stop'
if ($env:USERDOMAIN -and $env:USERDOMAIN -ne 'WORKGROUP' -and $env:USERDOMAIN -ne $env:COMPUTERNAME) {
$domain = $env:USERDOMAIN
} else {
$domain = $env:COMPUTERNAME
}
$user = "$domain\$env:USERNAME"
$action = New-ScheduledTaskAction -Execute $env:CUA_DRIVER_AS_EXE -Argument 'serve' -WorkingDirectory $env:USERPROFILE
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $user
$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit (New-TimeSpan -Hours 0)
Unregister-ScheduledTask -TaskName 'cua-driver-serve' -Confirm:$false -ErrorAction SilentlyContinue
Register-ScheduledTask -TaskName 'cua-driver-serve' -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Description 'cua-driver-rs: serve daemon, auto-start at interactive logon' | Out-Null
"#;

pub fn enable(exe: &str) -> Result<()> {
// Pass the binary path via env var so the script doesn't need
// shell-quoting acrobatics for paths with spaces or odd chars.
let out = Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", REGISTER_PS])
.env("CUA_DRIVER_AS_EXE", exe)
.output()
.map_err(|e| anyhow!("failed to invoke powershell: {e}"))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(anyhow!(
"PowerShell Register-ScheduledTask failed (exit {}): {}",
out.status.code().unwrap_or(-1),
stderr.trim()
));
}
Ok(())
}

pub fn disable() -> Result<()> {
// schtasks /Delete returns 0 on success, 1 on "task not found"
// (which we treat as success: the goal is "no task registered"
// and it already isn't). Match on stderr text rather than exit
// code because schtasks doesn't distinguish "doesn't exist" from
// "permission denied" via exit code.
let out = Command::new("schtasks")
.args(["/Delete", "/TN", TASK_NAME, "/F"])
.output()
.map_err(|e| anyhow!("failed to invoke schtasks: {e}"))?;
if out.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
let combined = format!("{stdout}{stderr}").to_lowercase();
if combined.contains("does not exist")
|| combined.contains("cannot find the file specified")
|| combined.contains("the system cannot find")
{
return Ok(());
}
Err(anyhow!(
"schtasks /Delete failed (exit {}): {}",
out.status.code().unwrap_or(-1),
stderr.trim()
))
}

pub fn status() -> Result<Status> {
// schtasks /Query exits 0 with task details on stdout, or 1 with
// "ERROR: The system cannot find the file specified." on stderr.
let out = Command::new("schtasks")
.args(["/Query", "/TN", TASK_NAME])
.output()
.map_err(|e| anyhow!("failed to invoke schtasks: {e}"))?;
if !out.status.success() {
return Ok(Status::NotRegistered);
}
Comment on lines +198 to +200

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

# First, let's locate and examine the autostart.rs file
find . -name "autostart.rs" -type f

Repository: trycua/cua

Length of output: 110


🏁 Script executed:

# Read the autostart.rs file to see the context around lines 183-185
cat -n libs/cua-driver-rs/crates/cua-driver/src/autostart.rs | head -200 | tail -50

Repository: trycua/cua

Length of output: 2417


🏁 Script executed:

# Get more context around the schtasks command usage
rg -n "schtasks" libs/cua-driver-rs/crates/cua-driver/src/autostart.rs -B 5 -A 5

Repository: trycua/cua

Length of output: 3209


🏁 Script executed:

# Search for how Status::NotRegistered is defined and used
rg -n "Status::" libs/cua-driver-rs/crates/cua-driver/src/autostart.rs | head -20

Repository: trycua/cua

Length of output: 388


Match stderr/stdout against specific error messages in status() to distinguish task-not-found from other failures.

The schtasks /Query command at line 183 treats all non-zero exit codes as Status::NotRegistered, which masks permission denied, tooling, or runtime errors as a false "not registered" state. The comment at lines 177-178 acknowledges that exit code 1 specifically indicates "the system cannot find the file specified," but the code doesn't verify this message.

The codebase already handles this correctly in the disable() function (lines 147-174), which checks stderr/stdout for specific error strings ("does not exist", "cannot find the file specified", "the system cannot find") to distinguish legitimate "not found" failures from actual errors. Apply the same pattern here to return an error for unexpected failures while returning Status::NotRegistered only when the error message confirms the task does not exist.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@libs/cua-driver-rs/crates/cua-driver/src/autostart.rs` around lines 183 -
185, In status(), don't treat every non-success exit from the schtasks /Query
command as Status::NotRegistered; instead mirror the disable() logic by
inspecting out.stderr/out.stdout for the "not found" messages (e.g. "does not
exist", "cannot find the file specified", "the system cannot find") and only
return Ok(Status::NotRegistered) when one of those substrings is present; for
other non-zero exits return an Err with the command output/error so callers can
distinguish permission/tool/runtime failures from a genuine missing task (refer
to the status() function and the existing disable() error-parsing logic to copy
the same message checks and error-return behavior).

// Registered — now check whether `cua-driver serve` is running.
// Avoid invoking `tasklist` (slow ~200ms on first run); use the
// same registry the daemon's own status command uses via a
// direct check on the named pipe.
if crate::serve::is_daemon_listening(&crate::serve::default_socket_path()) {
Ok(Status::RegisteredRunning)
} else {
Ok(Status::RegisteredIdle)
}
}

pub fn kick() -> Result<()> {
let out = Command::new("schtasks")
.args(["/Run", "/TN", TASK_NAME])
.output()
.map_err(|e| anyhow!("failed to invoke schtasks: {e}"))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
return Err(anyhow!(
"schtasks /Run failed (exit {}): {}",
out.status.code().unwrap_or(-1),
stderr.trim()
));
}
Ok(())
}
}

// ── macOS / Linux stubs ───────────────────────────────────────────────────

#[cfg(not(target_os = "windows"))]
mod platform {
use super::*;

const NOT_YET: &str =
"cua-driver autostart is currently Windows-only. macOS users: see \
libs/cua-driver-rs/scripts/install-local.sh --autostart for the \
LaunchAgent recipe. Linux users: same script registers a systemd \
--user unit. A cross-platform impl is tracked as a follow-up.";

pub fn enable(_exe: &str) -> Result<()> {
Err(anyhow!(NOT_YET))
}
pub fn disable() -> Result<()> {
Err(anyhow!(NOT_YET))
}
pub fn status() -> Result<Status> {
Err(anyhow!(NOT_YET))
}
pub fn kick() -> Result<()> {
Err(anyhow!(NOT_YET))
}
}

// ── CLI dispatcher ────────────────────────────────────────────────────────

/// `cua-driver autostart <subcommand>` entry point. Prints user-facing
/// output and exits the process via `std::process::exit` so the caller
/// (main) doesn't need to plumb back an exit code for every subcommand.
pub fn run_autostart_cmd(subcommand: &str) {
let (verb_result, success_text): (Result<()>, String) = match subcommand {
"enable" => (enable(), format!(
"Registered autostart entry '{TASK_NAME}'.\n \
cua-driver serve will start at every interactive logon."
)),
"disable" => (disable(), format!(
"Removed autostart entry '{TASK_NAME}' (no-op if it was already absent)."
)),
"status" => match status() {
Ok(s) => {
println!("{}", s.tag());
std::process::exit(0);
}
Err(e) => {
eprintln!("cua-driver autostart status: {e}");
std::process::exit(1);
}
},
"kick" => (kick(), format!(
"Started autostart entry '{TASK_NAME}' for the current session."
)),
other => {
eprintln!("Unknown autostart subcommand: {other:?}");
eprintln!("Usage: cua-driver autostart {{enable|disable|status|kick}}");
std::process::exit(64);
}
};
match verb_result {
Ok(()) => {
println!("{success_text}");
std::process::exit(0);
}
Err(e) => {
eprintln!("cua-driver autostart {subcommand}: {e}");
std::process::exit(1);
}
}
}
33 changes: 32 additions & 1 deletion libs/cua-driver-rs/crates/cua-driver/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ pub enum Command {
/// after install. Subsequent runs see the `.installation_recorded`
/// marker file and become no-ops.
TelemetryInstallEvent,
/// `cua-driver autostart {enable|disable|status|kick}` —
/// platform-native auto-start so `cua-driver serve` comes up on
/// every logon. Windows: Scheduled Task with LogonType=Interactive
/// (lands in Session 1+). macOS / Linux: not yet implemented; the
/// stub returns a helpful "use install-local.sh --autostart"
/// message. See `crates/cua-driver/src/autostart.rs`.
Autostart { subcommand: String },
}

/// Flags whose next token is a value (not a subcommand).
Expand All @@ -90,7 +97,13 @@ pub fn parse_command() -> Command {
if args.iter().any(|a| a == "--help" || a == "-h") {
println!("cua-driver {} — cross-platform computer-use automation driver", env!("CARGO_PKG_VERSION"));
println!("Usage: cua-driver [SUBCOMMAND] [OPTIONS]");
println!("Subcommands: mcp, list-tools, describe, call, serve, stop, status, config, recording, update, doctor, diagnose");
println!("Subcommands: mcp, list-tools, describe, call, serve, stop, status, config, recording, update, doctor, diagnose, autostart");
println!();
println!("autostart options (Windows-only today):");
println!(" cua-driver autostart enable Register a logon Scheduled Task so serve starts at every interactive logon.");
println!(" cua-driver autostart disable Remove the autostart entry. No-op if not registered.");
println!(" cua-driver autostart status Print whether the entry is registered + whether the daemon is running.");
println!(" cua-driver autostart kick Start the entry now without re-logging.");
println!();
println!("mcp options (macOS):");
println!(" --no-daemon-relaunch Stay in-process; skip auto-launching the CuaDriverRs daemon.");
Expand Down Expand Up @@ -188,6 +201,17 @@ pub fn parse_command() -> Command {
}
}
}
Some("autostart") => {
// No `cua-driver autostart` (no subcommand) shortcut today —
// every operation is destructive enough that we want the
// user to be explicit about which one.
let subcommand = pos.next().unwrap_or("").to_string();
if subcommand.is_empty() {
eprintln!("Usage: cua-driver autostart {{enable|disable|status|kick}}");
process::exit(64);
}
Command::Autostart { subcommand }
}
Some(first) => {
// Implicit call: unrecognised first positional → treat as tool name.
let tool = first.to_string();
Expand Down Expand Up @@ -1448,6 +1472,13 @@ pub fn telemetry_entry_event(cmd: &Command) -> Option<String> {
Command::Update { .. } => "cua_driver_update".to_owned(),
Command::Doctor { .. } => "cua_driver_doctor".to_owned(),
Command::Diagnose => "cua_driver_diagnose".to_owned(),
// Per-subcommand event so dashboards can split enable / disable /
// status / kick separately — they have very different meanings
// for adoption. `sanitize_tool_name` already does what we want
// (lowercase ASCII / underscore-only / max 64 chars / fallback).
Command::Autostart { subcommand } => {
format!("cua_driver_autostart_{}", sanitize_tool_name(subcommand))
}
Command::TelemetryInstallEvent => return None,
};
Some(name)
Expand Down
9 changes: 9 additions & 0 deletions libs/cua-driver-rs/crates/cua-driver/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
//!
//! On all other platforms `#[tokio::main]` is used directly.

mod autostart;
mod bundle;
mod cli;
mod doctor;
Expand Down Expand Up @@ -198,6 +199,10 @@ fn main() {
cli::run_diagnose_cmd(reg);
return;
}
cli::Command::Autostart { subcommand } => {
autostart::run_autostart_cmd(&subcommand);
return;
}
cli::Command::Config { subcommand, key, value, socket } => {
let reg = Arc::new(platform_macos::register_tools());
reg.init_self_weak();
Expand Down Expand Up @@ -403,6 +408,10 @@ fn main() -> anyhow::Result<()> {
cli::run_diagnose_cmd(reg);
return Ok(());
}
cli::Command::Autostart { subcommand } => {
autostart::run_autostart_cmd(&subcommand);
return Ok(());
}
cli::Command::Config { subcommand, key, value, socket } => {
let reg = Arc::new(build_registry_no_cursor());
reg.init_self_weak();
Expand Down
Loading
Loading