openlatch-client 0.6.13

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! OpenCode's binding (AGT-5).
//!
//! OpenCode is shaped like Cline's plugin lane, not like Cursor's hooks file:
//! every OpenCode client (the terminal UI, `run`, `serve`/`web`, ACP, Desktop)
//! talks to one local server process, and plugins load in that process. One
//! drop-in file, `<config root>/plugins/openlatch.js`, therefore covers every
//! surface on the host. Its surface is a [`HookSurface::PluginFile`]: there are
//! no hook entries to write, so [`AgentBinding::build_hook_entry`] is inert.
//!
//! OpenCode can refuse (the plugin throws) but cannot ask and cannot hold: an
//! Ask takes the policy author's fallback for non-prompting agents (C-2).

use std::path::{Path, PathBuf};

use serde_json::{json, Value};

use crate::core::envelope::alias::{AliasTable, McpKeyRule, MultiValueRule};
use crate::core::hook_state::marker::OpenlatchMarker;
use crate::error::{ERR_OPENCODE_PLUGIN_FOREIGN, ERR_OPENCODE_PLUGIN_PENDING};
use crate::hooks::binding::{
    AgentBinding, BindingCapabilities, DaemonChannel, Enforces, FailureMode, HookSurface,
    LivenessReport, ModelRelayWiring,
};
use crate::hooks::hook_files::{self, Existing};

/// OpenCode's alias table (C-4).
///
/// The native tools are lower-case; their arguments are camelCase and are
/// renamed onto the canonical keys. `apply_patch` and the MCP split
/// (`<server>_<tool>` against the configured servers) need the payload, not a
/// static row, and are applied in `to_common`.
pub static ALIASES: AliasTable = AliasTable {
    agent: OpencodeBinding::AGENT_TYPE,
    binding: "opencode_plugin",
    renders_binding: None,
    tools: &[
        ("bash", "Bash"),
        ("edit", "Edit"),
        ("write", "Write"),
        ("read", "Read"),
        ("grep", "Grep"),
        ("glob", "Glob"),
        ("webfetch", "WebFetch"),
        ("websearch", "WebSearch"),
        ("task", "Task"),
        ("todowrite", "TodoWrite"),
        ("skill", "Skill"),
    ],
    event_tools: &[],
    keep: &[],
    inputs: &[
        ("*", "filePath", "file_path"),
        ("*", "oldString", "old_string"),
        ("*", "newString", "new_string"),
        ("*", "replaceAll", "replace_all"),
    ],
    creates: &[],
    mcp_rule: McpKeyRule::NATIVE,
    multi: MultiValueRule::NONE,
};

/// The OpenCode hooks the plugin registers. Not written anywhere — the plugin
/// file is the registration — and listed so health and doctor speak OpenCode's
/// own names.
const EVENT_TYPES: &[&str] = &[
    "tool.execute.before",
    "chat.message",
    "tool.execute.after",
    "event",
];

/// The two that decide.
const LOAD_BEARING: &[&str] = &["tool.execute.before", "chat.message"];

/// OpenCode, bound at its config root.
pub struct OpencodeBinding {
    /// The config root (`$XDG_CONFIG_HOME/opencode`, else `~/.config/opencode`).
    pub root: PathBuf,
}

impl OpencodeBinding {
    /// CloudEvents `source`.
    pub const AGENT_TYPE: &'static str = "opencode";

    /// Present when the CLI or the Desktop application is installed.
    pub fn detect() -> Option<Self> {
        crate::hooks::opencode::detect().map(|root| Self { root })
    }

    /// The plugins folder.
    #[must_use]
    pub fn plugins_dir(&self) -> PathBuf {
        crate::hooks::opencode::plugins_dir(&self.root)
    }

    /// Our plugin file.
    #[must_use]
    pub fn plugin_path(&self) -> PathBuf {
        crate::hooks::opencode::plugin_path(&self.root)
    }

    /// Liveness from the two facts that decide it: what holds our path, and
    /// whether a load report arrived after the file was written.
    pub(crate) fn liveness_from(existing: Existing, seen_after_write: bool) -> LivenessReport {
        let report = |armed, detail: &str, remedy: Option<&str>, code, pending| LivenessReport {
            armed,
            detail: Some(detail.to_string()),
            remedy: remedy.map(str::to_string),
            code,
            off: false,
            pending,
            settling: false,
        };
        match existing {
            // Health reports a missing plugin as a missing registration.
            Existing::Nothing => report(
                None,
                "the OpenLatch plugin is not installed",
                None,
                None,
                false,
            ),
            Existing::Theirs => report(
                Some(false),
                "a file OpenLatch does not own holds the OpenCode plugin path",
                Some("Move that file, then rerun `openlatch init --agent opencode`."),
                Some(ERR_OPENCODE_PLUGIN_FOREIGN),
                false,
            ),
            Existing::Ours if seen_after_write => report(
                Some(true),
                "OpenCode loaded the OpenLatch plugin",
                None,
                None,
                false,
            ),
            Existing::Ours => report(
                Some(false),
                "the plugin is installed and OpenCode has not loaded it since",
                Some("Restart OpenCode; this check turns green on the plugin's first load report."),
                Some(ERR_OPENCODE_PLUGIN_PENDING),
                true,
            ),
        }
    }
}

impl AgentBinding for OpencodeBinding {
    fn agent_type(&self) -> &'static str {
        Self::AGENT_TYPE
    }

    fn aliases(&self) -> &'static AliasTable {
        &ALIASES
    }

    fn display_name(&self) -> &'static str {
        "OpenCode"
    }

    fn config_dir(&self) -> PathBuf {
        self.root.clone()
    }

    fn hook_config_path(&self) -> PathBuf {
        self.plugin_path()
    }

    fn hook_surface(&self) -> HookSurface {
        HookSurface::PluginFile(self.plugin_path())
    }

    fn hook_event_types(&self) -> &'static [&'static str] {
        EVENT_TYPES
    }

    fn load_bearing_events(&self) -> &'static [&'static str] {
        LOAD_BEARING
    }

    fn daemon_channel(&self) -> DaemonChannel {
        // The plugin passes `--openlatch-dir`; the hook reads port and token
        // from there. No secret is ever rendered into the plugin file.
        DaemonChannel::OpenlatchDirArg
    }

    fn liveness(&self) -> LivenessReport {
        let path = self.plugin_path();
        let existing = hook_files::whatever_is_there(&path);
        let ol_dir = crate::config::openlatch_dir();
        let seen = crate::daemon::opencode_plugin_seen::read_seen(&ol_dir);
        let seen_after_write = crate::daemon::opencode_plugin_seen::mtime_ms(&path)
            .is_some_and(|written| crate::daemon::opencode_plugin_seen::seen_since(&seen, written));
        Self::liveness_from(existing, seen_after_write)
    }

    fn build_hook_entry(
        &self,
        _event: &str,
        _binary: &Path,
        _port: u16,
        _marker: &OpenlatchMarker,
    ) -> Value {
        // Inert, never a panic: a plugin-file surface writes no entries, and
        // `install_hooks` dispatches it before any entry is built.
        json!({})
    }

    fn config_is_machine_global(&self) -> bool {
        crate::hooks::opencode::config_is_machine_global()
    }

    fn capabilities(&self) -> BindingCapabilities {
        BindingCapabilities {
            // Block is a throw; Ask has no lane (OpenCode never calls its
            // `permission.ask` plugin hook), so it is not expressible here and
            // takes the author's fallback for non-prompting agents.
            expressible: &["allow", "deny"],
            can_mutate_arguments: false,
            native_failure_mode: FailureMode::FailOpen,
            admin_owned_settings: false,
            declares_session_in_request: true,
            notice: false,
            observes_execution: true,
            enforces: Enforces::All,
            // The plugin's 1500 ms budget would cut any hold short.
            can_hold: false,
        }
    }

    fn model_relay_wiring(&self) -> Option<ModelRelayWiring> {
        // The request plane is AGT-5-P2's (ProxyEnv + EnvFile for `opencode`).
        None
    }

    fn tamper_watch_paths(&self) -> Vec<PathBuf> {
        vec![self.plugin_path()]
    }
}

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

    fn binding() -> OpencodeBinding {
        OpencodeBinding {
            root: PathBuf::from("/home/test/.config/opencode"),
        }
    }

    #[test]
    fn surface_is_the_plugin_file() {
        let b = binding();
        assert_eq!(
            b.hook_surface(),
            HookSurface::PluginFile(
                PathBuf::from("/home/test/.config/opencode")
                    .join("plugins")
                    .join("openlatch.js")
            )
        );
        assert_eq!(b.hook_config_path(), b.plugin_path());
    }

    #[test]
    fn opencode_cannot_ask_or_hold_and_blocks_every_tool() {
        let caps = binding().capabilities();
        assert!(!caps.ask());
        assert!(!caps.can_hold);
        assert!(!caps.can_mutate_arguments);
        assert!(caps.expressible.contains(&"deny"));
        assert_eq!(caps.enforces, Enforces::All);
    }

    #[test]
    fn the_binding_id_is_opencode_plugin() {
        assert_eq!(binding().binding_id(false), "opencode_plugin");
        assert_eq!(
            crate::hooks::bindings::binding_id("opencode", false),
            "opencode_plugin"
        );
    }

    #[test]
    fn liveness_is_pending_until_a_load_report_follows_the_write() {
        let pending = OpencodeBinding::liveness_from(Existing::Ours, false);
        assert_eq!(pending.armed, Some(false));
        assert!(pending.pending);
        assert_eq!(pending.code, Some(ERR_OPENCODE_PLUGIN_PENDING));
        assert!(pending.remedy.is_some());

        let seen = OpencodeBinding::liveness_from(Existing::Ours, true);
        assert_eq!(seen.armed, Some(true));

        let foreign = OpencodeBinding::liveness_from(Existing::Theirs, true);
        assert_eq!(foreign.code, Some(ERR_OPENCODE_PLUGIN_FOREIGN));
        assert!(foreign.remedy.is_some());
    }

    #[test]
    fn the_command_plane_has_no_request_plane_yet() {
        assert!(binding().model_relay_wiring().is_none());
        assert!(binding().provider_endpoints().is_none());
    }
}