regy 0.1.0

Private-by-default desktop agent for the Regy web interface
use std::{
    collections::BTreeMap,
    ffi::OsString,
    fs,
    io::Write,
    os::unix::fs::{OpenOptionsExt, PermissionsExt},
    path::{Path, PathBuf},
    time::Duration,
};

use uuid::Uuid;

use crate::{
    dependencies::command::{CommandRunner, CommandSpec},
    domain::errors::{AgentError, AgentResult, ErrorCode},
};

const COMMAND_TIMEOUT: Duration = Duration::from_secs(2 * 60);
const MAX_SETTINGS_BYTES: u64 = 1024 * 1024;

pub(crate) const CLAUDE_AUTH_EXTENSION: PiPackageSource =
    PiPackageSource::new("@cgaravitoq/pi-claude-code-auth", "2.3.0");
pub(crate) const ANTIGRAVITY_EXTENSION: PiPackageSource =
    PiPackageSource::new("@raquezha/antigravity", "0.0.9");

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct PiPackageSource {
    pub(crate) name: &'static str,
    pub(crate) version: &'static str,
}

impl PiPackageSource {
    pub(crate) const fn new(name: &'static str, version: &'static str) -> Self {
        Self { name, version }
    }

    pub(crate) fn exact(self) -> String {
        format!("npm:{}@{}", self.name, self.version)
    }

    fn is_reviewed(self) -> bool {
        self == CLAUDE_AUTH_EXTENSION || self == ANTIGRAVITY_EXTENSION
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ManagedPiRuntime {
    pub(crate) pi: PathBuf,
    pub(crate) node: PathBuf,
    pub(crate) npm: PathBuf,
    pub(crate) tools: PathBuf,
    pub(crate) settings: PathBuf,
}

impl ManagedPiRuntime {
    pub(crate) fn new(
        pi: impl Into<PathBuf>,
        node: impl Into<PathBuf>,
        npm: impl Into<PathBuf>,
        tools: impl Into<PathBuf>,
        settings: impl Into<PathBuf>,
    ) -> Self {
        Self {
            pi: pi.into(),
            node: node.into(),
            npm: npm.into(),
            tools: tools.into(),
            settings: settings.into(),
        }
    }
}

pub(crate) struct ManagedPi<'a> {
    runtime: ManagedPiRuntime,
    runner: &'a dyn CommandRunner,
}

impl<'a> ManagedPi<'a> {
    pub(crate) fn new(runtime: ManagedPiRuntime, runner: &'a dyn CommandRunner) -> Self {
        Self { runtime, runner }
    }

    pub(crate) async fn install_extension(&self, source: &PiPackageSource) -> AgentResult<()> {
        let source = *source;
        if !source.is_reviewed() {
            return Err(extension_failed());
        }
        let runtime = validate_runtime(&self.runtime)?;
        let snapshot = SettingsSnapshot::capture(&self.runtime.settings)?;
        configure_managed_npm(
            &self.runtime.settings,
            &runtime.node,
            &runtime.npm,
            &snapshot,
        )?;

        let install = CommandSpec {
            program: runtime.node.clone(),
            args: vec![
                runtime.pi.clone().into_os_string(),
                OsString::from("install"),
                OsString::from(source.exact()),
            ],
            env: BTreeMap::new(),
            cwd: Some(runtime.cwd.clone()),
            timeout: COMMAND_TIMEOUT,
        };
        if let Err(error) = self.runner.output(&install).await {
            snapshot.restore(&self.runtime.settings)?;
            return Err(extension_command_failed(error));
        }

        let list = CommandSpec {
            program: runtime.node,
            args: vec![runtime.pi.into_os_string(), OsString::from("list")],
            env: BTreeMap::new(),
            cwd: Some(runtime.cwd),
            timeout: COMMAND_TIMEOUT,
        };
        let output = match self.runner.output(&list).await {
            Ok(output) => output,
            Err(error) => {
                snapshot.restore(&self.runtime.settings)?;
                return Err(extension_confirmation_command_failed(error));
            }
        };
        if !registered_exactly(&output.stdout, &source.exact()) {
            snapshot.restore(&self.runtime.settings)?;
            return Err(extension_confirmation_failed());
        }
        Ok(())
    }
}

struct ValidatedRuntime {
    pi: PathBuf,
    node: PathBuf,
    npm: PathBuf,
    cwd: PathBuf,
}

fn validate_runtime(runtime: &ManagedPiRuntime) -> AgentResult<ValidatedRuntime> {
    let tools = fs::canonicalize(&runtime.tools).map_err(|_| extension_filesystem_failed())?;
    if fs::symlink_metadata(&runtime.tools)
        .is_ok_and(|metadata| metadata.file_type().is_symlink() || !metadata.is_dir())
    {
        return Err(extension_filesystem_failed());
    }
    let pi = contained_executable(&runtime.pi, &tools)?;
    let node = contained_executable(&runtime.node, &tools)?;
    let npm = contained_executable(&runtime.npm, &tools)?;
    let cwd = settings_cwd(&runtime.settings)?;
    Ok(ValidatedRuntime { pi, node, npm, cwd })
}

fn settings_cwd(settings: &Path) -> AgentResult<PathBuf> {
    let parent = settings.parent().ok_or_else(extension_filesystem_failed)?;
    if !parent.is_absolute() {
        return Err(extension_filesystem_failed());
    }
    let metadata = fs::symlink_metadata(parent).map_err(|_| extension_filesystem_failed())?;
    if metadata.file_type().is_symlink() || !metadata.is_dir() {
        return Err(extension_filesystem_failed());
    }
    let canonical = fs::canonicalize(parent).map_err(|_| extension_filesystem_failed())?;
    let canonical_metadata = fs::metadata(&canonical).map_err(|_| extension_filesystem_failed())?;
    if !canonical_metadata.is_dir() {
        return Err(extension_filesystem_failed());
    }
    Ok(canonical)
}

fn contained_executable(path: &Path, tools: &Path) -> AgentResult<PathBuf> {
    let canonical = fs::canonicalize(path).map_err(|_| extension_filesystem_failed())?;
    let metadata = fs::metadata(&canonical).map_err(|_| extension_filesystem_failed())?;
    if !canonical.starts_with(tools)
        || !metadata.is_file()
        || metadata.permissions().mode() & 0o111 == 0
    {
        return Err(extension_filesystem_failed());
    }
    Ok(canonical)
}

enum SettingsSnapshot {
    Missing,
    File { bytes: Vec<u8>, mode: u32 },
}

impl SettingsSnapshot {
    fn capture(path: &Path) -> AgentResult<Self> {
        match fs::symlink_metadata(path) {
            Ok(metadata) => {
                if metadata.file_type().is_symlink()
                    || !metadata.is_file()
                    || metadata.len() > MAX_SETTINGS_BYTES
                {
                    return Err(extension_filesystem_failed());
                }
                let bytes = fs::read(path).map_err(|_| extension_filesystem_failed())?;
                Ok(Self::File {
                    bytes,
                    mode: metadata.permissions().mode() & 0o777,
                })
            }
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::Missing),
            Err(_) => Err(extension_filesystem_failed()),
        }
    }

    fn restore(&self, path: &Path) -> AgentResult<()> {
        match self {
            Self::Missing => {
                if path.exists() {
                    fs::remove_file(path).map_err(|_| extension_filesystem_failed())?;
                    sync_parent(path)?;
                }
                Ok(())
            }
            Self::File { bytes, mode } => write_settings(path, bytes, *mode),
        }
    }
}

fn configure_managed_npm(
    path: &Path,
    node: &Path,
    npm: &Path,
    snapshot: &SettingsSnapshot,
) -> AgentResult<()> {
    if !node.is_absolute() || !npm.is_absolute() {
        return Err(extension_filesystem_failed());
    }
    let node = node.to_str().ok_or_else(extension_filesystem_failed)?;
    let npm = npm.to_str().ok_or_else(extension_filesystem_failed)?;
    let mut settings = match snapshot {
        SettingsSnapshot::Missing => serde_json::Map::new(),
        SettingsSnapshot::File { bytes, .. } => {
            let value: serde_json::Value =
                serde_json::from_slice(bytes).map_err(|_| extension_filesystem_failed())?;
            value
                .as_object()
                .cloned()
                .ok_or_else(extension_filesystem_failed)?
        }
    };
    settings.insert(
        "npmCommand".to_owned(),
        serde_json::Value::Array(vec![
            serde_json::Value::String(node.to_owned()),
            serde_json::Value::String(npm.to_owned()),
        ]),
    );
    let bytes = serde_json::to_vec(&settings).map_err(|_| extension_filesystem_failed())?;
    write_settings(path, &bytes, 0o600)
}

fn write_settings(path: &Path, bytes: &[u8], mode: u32) -> AgentResult<()> {
    let parent = path.parent().ok_or_else(extension_filesystem_failed)?;
    fs::create_dir_all(parent).map_err(|_| extension_filesystem_failed())?;
    let parent_metadata =
        fs::symlink_metadata(parent).map_err(|_| extension_filesystem_failed())?;
    if parent_metadata.file_type().is_symlink() || !parent_metadata.is_dir() {
        return Err(extension_filesystem_failed());
    }
    if fs::symlink_metadata(path)
        .is_ok_and(|metadata| metadata.file_type().is_symlink() || !metadata.is_file())
    {
        return Err(extension_filesystem_failed());
    }
    let temporary = parent.join(format!(".settings-{}.tmp", Uuid::new_v4()));
    let mut file = fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .mode(mode)
        .open(&temporary)
        .map_err(|_| extension_filesystem_failed())?;
    let result = (|| {
        file.write_all(bytes)?;
        file.set_permissions(fs::Permissions::from_mode(mode))?;
        file.sync_all()?;
        fs::rename(&temporary, path)?;
        fs::File::open(parent)?.sync_all()
    })();
    if result.is_err() {
        let _ = fs::remove_file(&temporary);
        return Err(extension_filesystem_failed());
    }
    Ok(())
}

fn sync_parent(path: &Path) -> AgentResult<()> {
    fs::File::open(path.parent().ok_or_else(extension_filesystem_failed)?)
        .and_then(|directory| directory.sync_all())
        .map_err(|_| extension_filesystem_failed())
}

fn registered_exactly(stdout: &[u8], expected: &str) -> bool {
    std::str::from_utf8(stdout)
        .ok()
        .is_some_and(|output| output.lines().any(|line| line.trim() == expected))
}

fn extension_failed() -> AgentError {
    AgentError::new(
        ErrorCode::SkillDownloadFailed,
        "Pi extension install failed",
    )
}
fn extension_command_failed(error: AgentError) -> AgentError {
    AgentError::new(
        ErrorCode::SkillDownloadFailed,
        format!("Pi extension install failed: {}", error.message()),
    )
}
fn extension_confirmation_failed() -> AgentError {
    AgentError::new(ErrorCode::SkillInvalid, "Pi extension confirmation failed")
}
fn extension_confirmation_command_failed(error: AgentError) -> AgentError {
    AgentError::new(
        ErrorCode::SkillInvalid,
        format!("Pi extension confirmation failed: {}", error.message()),
    )
}
fn extension_filesystem_failed() -> AgentError {
    AgentError::new(
        ErrorCode::SkillFilesystemFailed,
        "Pi extension settings operation failed",
    )
}