openlatch-client 0.6.15

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, EndpointConvention, Enforces, EnvCommand,
    FailureMode, HookSurface, LivenessReport, ModelRelayWiring, ProxyDelivery,
};
use crate::hooks::hook_files::{self, Existing};
use crate::model_relay::wire_format::WireFormat;

/// 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 hosts OpenCode's model traffic is intercepted on (AGT-5 C-6): a fixed constant, grown from
/// evidence. `opencode.ai` is first, so the preflight proves trust against OpenCode's own host. A
/// custom `provider.*.options.baseURL` host is never added here, whatever the config says: config
/// never decides which host our CA mints a leaf for (Plan D-11), so such a host is tunnelled.
pub const OPENCODE_INTERCEPT_HOSTS: &[&str] = &[
    "opencode.ai",
    "api.anthropic.com",
    "api.openai.com",
    "generativelanguage.googleapis.com",
    "openrouter.ai",
];

/// The variable that turns on OpenCode's WebSocket transport for OpenAI turns, which the relay
/// cannot capture (C-6). Pinned off on stable builds; a value the developer set is never
/// overwritten.
pub const WEBSOCKETS_ENV: &str = "OPENCODE_EXPERIMENTAL_WEBSOCKETS";

/// The `opencode` command, with loopback exempted from the proxy: the terminal UI talks to its
/// own server over loopback (C-6).
pub(crate) const OPENCODE_COMMANDS: &[EnvCommand] = &[EnvCommand::loopback_direct("opencode")];

/// The CLI surface's delivery (Plan D-3/D-4). Desktop has none in this build: its request plane is
/// macOS-only and gated on probe P-4b, so doctor reports `relay.desktop = unsupported`.
const OPENCODE_DELIVERY: &[ProxyDelivery] = &[ProxyDelivery::EnvFile {
    commands: OPENCODE_COMMANDS,
    defaults: OPENCODE_DEFAULTS,
}];

/// The environment every wrapped `opencode` runs with: WebSockets pinned off (C-6).
pub(crate) const OPENCODE_DEFAULTS: &[(&str, &str)] = &[(WEBSOCKETS_ENV, "false")];

/// 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> {
        // Intercepted (Plan D-3): fixed hosts only, the `opencode` command pointed at the relay by
        // the generated env file. No `provider_endpoints()`, so no loopback rewrite and no dynamic
        // host — no `opencode.json` is ever edited. The format is a placeholder a ProxyEnv plane
        // never records: each request's own route decides its format.
        Some(ModelRelayWiring {
            wire_format: WireFormat::OpenAiChatCompletions,
            endpoint: EndpointConvention::ProxyEnv {
                intercept_hosts: OPENCODE_INTERCEPT_HOSTS,
                delivery: OPENCODE_DELIVERY,
                h2_hosts: &[],
            },
            install_id_header: "x-openlatch-install-id",
        })
    }

    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());
    }

    /// Plan D-3/D-4/D-11: one intercepted lane on fixed hosts, the `opencode` command wrapped
    /// with loopback exempted and the WebSocket pin as a default, no provider slots (so the
    /// endpoint wiring skips OpenCode) and no h2 host.
    #[test]
    fn the_request_plane_is_proxy_env_on_fixed_hosts() {
        let b = binding();
        assert!(b.provider_endpoints().is_none(), "no base-URL lane (D-3)");
        match b
            .model_relay_wiring()
            .expect("OpenCode declares ProxyEnv")
            .endpoint
        {
            EndpointConvention::ProxyEnv {
                intercept_hosts,
                delivery,
                h2_hosts,
            } => {
                assert_eq!(intercept_hosts, OPENCODE_INTERCEPT_HOSTS);
                assert_eq!(intercept_hosts[0], "opencode.ai", "the probe host is first");
                assert!(h2_hosts.is_empty());
                assert!(intercept_hosts
                    .iter()
                    .all(|h| *h == h.to_ascii_lowercase() && !h.contains('*')));
                assert!(matches!(
                    delivery,
                    [ProxyDelivery::EnvFile {
                        commands: [EnvCommand {
                            name: "opencode",
                            resolve_guard: None,
                            loopback_direct: true,
                        }],
                        defaults: [(WEBSOCKETS_ENV, "false")],
                    }]
                ));
            }
            other => panic!("expected ProxyEnv, got {other:?}"),
        }
    }
}