openlatch-client 0.6.21

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
//! OpenCode path resolution and detection (AGT-5 C-1).
//!
//! The **only** place the OpenCode config root is resolved. The binding, the
//! plugin writer, the attestation and the config-monitor manifest all ask here.
//!
//! The root is exactly OpenCode's own resolution — `$XDG_CONFIG_HOME/opencode`,
//! else `~/.config/opencode`, on every OS — so the folder we write is always the
//! one OpenCode loads plugins from. There is deliberately no OpenLatch-specific
//! override: OpenCode's own `XDG_*` variables are the isolation seams, as
//! `CODEX_HOME` is for Codex. `OPENCODE_CONFIG_DIR` adds a config directory to
//! OpenCode's search — and **replaces** the global `AGENTS.md` — but the global
//! directory, and so its plugins folder, is always loaded: the plugin is
//! written only there. The config monitor inventories the added directory
//! (`daemon::config_monitor::opencode_select`, AGT-6 C-4).
//!
//! Detection is the CLI or the Desktop application being installed. A leftover
//! config root alone is **not** a detection.

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

/// OpenCode's own seam for its config root. Isolated instances set it.
pub const XDG_CONFIG_HOME_ENV: &str = "XDG_CONFIG_HOME";

/// Every seam an isolated instance sets for OpenCode: config, data, state and
/// cache. The client reads only the first; the sandbox tool and the E2E gate
/// require all four, because OpenCode itself writes into each.
pub const SEAMS: &[&str] = &[
    "XDG_CONFIG_HOME",
    "XDG_DATA_HOME",
    "XDG_STATE_HOME",
    "XDG_CACHE_HOME",
];

/// The directory name under the config home.
const DIR_NAME: &str = "opencode";

/// The plugins folder we write. OpenCode also reads `plugin/`; we always write
/// `plugins/`.
pub const PLUGINS_DIR_NAME: &str = "plugins";

/// Our plugin file's name inside [`PLUGINS_DIR_NAME`].
pub const PLUGIN_FILE_NAME: &str = "openlatch.js";

/// The OpenCode version this build supports from (C-5 `cli.supported`).
pub const MIN_SUPPORTED_VERSION: (u64, u64, u64) = (1, 17, 0);

/// How the config root was located (C-5 `config_root.located`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Located {
    /// `~/.config/opencode`.
    Default,
    /// `$XDG_CONFIG_HOME/opencode`.
    Xdg,
}

impl Located {
    /// The wire token: `default` or `xdg`.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Default => "default",
            Self::Xdg => "xdg",
        }
    }
}

fn xdg_config_home() -> Option<PathBuf> {
    xdg_config_home_from(
        std::env::var_os(XDG_CONFIG_HOME_ENV),
        std::env::current_dir().ok().as_deref(),
    )
}

/// OpenCode (via xdg-basedir) uses `XDG_CONFIG_HOME` as given, so a relative
/// value is the folder under the cwd OpenCode starts in. We resolve it the same
/// way, against our own cwd, and keep the absolute result: `init` run from that
/// folder writes where OpenCode loads, and the state row records an absolute
/// path the daemon can heal from any cwd. Empty reads as unset; a relative value
/// with no cwd to resolve against is unusable and reads as unset too.
pub(crate) fn xdg_config_home_from(
    value: Option<std::ffi::OsString>,
    cwd: Option<&Path>,
) -> Option<PathBuf> {
    let path = PathBuf::from(value.filter(|v| !v.is_empty())?);
    if path.is_absolute() {
        return Some(path);
    }
    Some(cwd?.join(path))
}

fn default_root() -> Option<PathBuf> {
    dirs::home_dir().map(|home| home.join(".config").join(DIR_NAME))
}

/// The config root and how it was located.
#[must_use]
pub fn root_located() -> Option<(PathBuf, Located)> {
    root_located_in(xdg_config_home(), dirs::home_dir().as_deref())
}

pub(crate) fn root_located_in(
    xdg: Option<PathBuf>,
    home: Option<&Path>,
) -> Option<(PathBuf, Located)> {
    if let Some(xdg) = xdg {
        return Some((xdg.join(DIR_NAME), Located::Xdg));
    }
    Some((home?.join(".config").join(DIR_NAME), Located::Default))
}

/// The config root: `$XDG_CONFIG_HOME/opencode`, else `~/.config/opencode`.
#[must_use]
pub fn root() -> Option<PathBuf> {
    root_located().map(|(p, _)| p)
}

/// `<root>/plugins`.
#[must_use]
pub fn plugins_dir(root: &Path) -> PathBuf {
    root.join(PLUGINS_DIR_NAME)
}

/// `<root>/plugins/openlatch.js`.
#[must_use]
pub fn plugin_path(root: &Path) -> PathBuf {
    plugins_dir(root).join(PLUGIN_FILE_NAME)
}

/// True when the root resolves to the machine user's default location.
#[must_use]
pub fn config_is_machine_global() -> bool {
    let (Some(resolved), Some(default)) = (root(), default_root()) else {
        return true;
    };
    let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
    canonical(&resolved) == canonical(&default)
}

/// The seam a fixture sets so nothing it installs reaches the developer's own
/// `~/.config/opencode`: a config home that is never created.
#[must_use]
pub fn absent_seam(root: &Path) -> (&'static str, PathBuf) {
    (XDG_CONFIG_HOME_ENV, root.join("absent-opencode-config"))
}

/// Which OpenCode surfaces are installed on this host.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Installed {
    /// The CLI binary, when found.
    pub cli: Option<PathBuf>,
    /// The Desktop application, when found.
    pub desktop: Option<PathBuf>,
}

impl Installed {
    /// Either surface is present.
    #[must_use]
    pub fn any(&self) -> bool {
        self.cli.is_some() || self.desktop.is_some()
    }
}

/// Detect OpenCode on this host. `None` when neither the CLI nor the Desktop
/// application is installed — a leftover config root is not a detection.
#[must_use]
pub fn detect() -> Option<PathBuf> {
    installed().any().then(root).flatten()
}

/// The installed surfaces, from the real environment.
#[must_use]
pub fn installed() -> Installed {
    match dirs::home_dir() {
        Some(home) => installed_in(
            &home,
            Some(crate::core::user_path::effective_path().as_os_str()),
        ),
        None => Installed::default(),
    }
}

pub(crate) fn installed_in(home: &Path, path_var: Option<&std::ffi::OsStr>) -> Installed {
    Installed {
        cli: cli_location(home, path_var),
        desktop: desktop_locations(home).into_iter().find(|p| p.exists()),
    }
}

/// The CLI: `opencode` on an absolute `PATH` entry (npm and Homebrew installs),
/// else the official install script's target `~/.opencode/bin/opencode`.
fn cli_location(home: &Path, path_var: Option<&std::ffi::OsStr>) -> Option<PathBuf> {
    if let Some(found) = find_on_path(path_var, "opencode") {
        return Some(found);
    }
    let name = if cfg!(windows) {
        "opencode.exe"
    } else {
        "opencode"
    };
    let script = home.join(".opencode").join("bin").join(name);
    script.is_file().then_some(script)
}

/// An executable named `name` on an **absolute** entry of `path_var`.
///
/// A relative entry is skipped for the reason `identity::git_email` skips it: it would resolve
/// against whatever cwd the process has. On Windows each of the usual launcher extensions is
/// tried. Production callers pass `core::user_path::effective_path()`, which never contains the
/// launcher shims' folder — a shim is not an installed CLI.
pub(crate) fn find_on_path(path_var: Option<&std::ffi::OsStr>, name: &str) -> Option<PathBuf> {
    let path_var = path_var?;
    let names: Vec<String> = if cfg!(windows) {
        ["exe", "cmd", "ps1"]
            .iter()
            .map(|ext| format!("{name}.{ext}"))
            .collect()
    } else {
        vec![name.to_string()]
    };
    std::env::split_paths(path_var)
        .filter(|dir| dir.is_absolute())
        .flat_map(|dir| names.iter().map(move |n| dir.join(n)))
        .find(|p| p.is_file())
}

/// The folders under `%LOCALAPPDATA%\Programs` the Desktop installer uses on Windows; 2.0 installs
/// to `@opencodedesktop`.
pub(crate) const WINDOWS_INSTALL_DIRS: [&str; 4] = [
    "@opencodedesktop",
    "OpenCode",
    "opencode",
    "ai.opencode.desktop",
];

/// Where the Desktop application (Electron; product `OpenCode`, app id
/// `ai.opencode.desktop`) installs. Read for presence and bundle metadata only —
/// never launched.
pub(crate) fn desktop_locations(home: &Path) -> Vec<PathBuf> {
    let mut out = Vec::new();
    if cfg!(target_os = "macos") {
        out.push(PathBuf::from("/Applications").join("OpenCode.app")); // portability-ok: macOS bundle root, reached only when cfg!(target_os = "macos")
        out.push(home.join("Applications").join("OpenCode.app"));
    }
    if cfg!(windows) {
        if let Some(local) = dirs::data_local_dir() {
            let programs = local.join("Programs");
            out.extend(
                WINDOWS_INSTALL_DIRS
                    .iter()
                    .map(|d| programs.join(d).join("OpenCode.exe")),
            );
        }
    }
    if cfg!(target_os = "linux") {
        out.push(PathBuf::from("/usr/bin").join("ai.opencode.desktop")); // portability-ok: Linux package binary, reached only when cfg!(target_os = "linux")
        out.push(
            PathBuf::from("/usr/share/applications").join("ai.opencode.desktop.desktop"), // portability-ok: Linux desktop entry, reached only when cfg!(target_os = "linux")
        );
        out.push(
            home.join(".local")
                .join("share")
                .join("applications")
                .join("ai.opencode.desktop.desktop"),
        );
    }
    out
}

/// OpenCode's managed-config directory (C-7): `/Library/Application
/// Support/opencode` on macOS, `/etc/opencode` on Linux, `%ProgramData%\opencode`
/// on Windows. Read as inventory, never written. `None` where it cannot be named.
#[must_use]
pub fn managed_dir() -> Option<PathBuf> {
    if cfg!(target_os = "macos") {
        return Some(PathBuf::from("/Library/Application Support/opencode")); // portability-ok: macOS managed-config root, reached only when cfg!(target_os = "macos")
    }
    if cfg!(windows) {
        return std::env::var_os("ProgramData").map(|d| PathBuf::from(d).join("opencode"));
    }
    Some(PathBuf::from("/etc/opencode")) // portability-ok: Linux managed-config root, reached on Linux and other Unix only
}

/// Parse `opencode --version` output (`1.18.35`, possibly prefixed `v`).
#[must_use]
pub fn parse_version(text: &str) -> Option<(u64, u64, u64)> {
    let token = text.split_whitespace().find(|t| {
        t.trim_start_matches('v')
            .chars()
            .next()
            .is_some_and(|c| c.is_ascii_digit())
    })?;
    let core = token
        .trim_start_matches('v')
        .split(['-', '+'])
        .next()
        .unwrap_or_default();
    let mut parts = core.split('.').map(|p| p.parse::<u64>().ok());
    Some((
        parts.next()??,
        parts.next()??,
        parts.next().flatten().unwrap_or(0),
    ))
}

/// Whether a version meets [`MIN_SUPPORTED_VERSION`].
#[must_use]
pub fn version_supported(version: (u64, u64, u64)) -> bool {
    version >= MIN_SUPPORTED_VERSION
}

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

    #[test]
    fn a_relative_xdg_config_home_resolves_against_the_cwd_as_opencode_does() {
        let cwd = std::env::temp_dir().join("work-repo");
        let absolute = std::env::temp_dir().join("abs-cfg");
        assert_eq!(
            xdg_config_home_from(Some("agent-config".into()), Some(&cwd)),
            Some(cwd.join("agent-config"))
        );
        assert_eq!(
            xdg_config_home_from(Some(absolute.clone().into_os_string()), Some(&cwd)),
            Some(absolute)
        );
        assert_eq!(xdg_config_home_from(Some("".into()), Some(&cwd)), None);
        assert_eq!(xdg_config_home_from(None, Some(&cwd)), None);
        assert_eq!(xdg_config_home_from(Some("rel".into()), None), None);
        let (root, located) = root_located_in(
            xdg_config_home_from(Some("agent-config".into()), Some(&cwd)),
            Some(Path::new("/h")),
        )
        .unwrap();
        assert_eq!(root, cwd.join("agent-config").join("opencode"));
        assert_eq!(located, Located::Xdg);
    }

    #[test]
    fn root_is_xdg_when_set_else_home_config() {
        let (p, l) = root_located_in(Some(PathBuf::from("/x/cfg")), Some(Path::new("/h"))).unwrap();
        assert_eq!(p, PathBuf::from("/x/cfg").join("opencode"));
        assert_eq!(l, Located::Xdg);
        let (p, l) = root_located_in(None, Some(Path::new("/h"))).unwrap();
        assert_eq!(p, Path::new("/h").join(".config").join("opencode"));
        assert_eq!(l, Located::Default);
    }

    #[test]
    fn plugin_path_is_plugins_openlatch_js() {
        let p = plugin_path(Path::new("/r"));
        assert_eq!(p, Path::new("/r").join("plugins").join("openlatch.js"));
    }

    #[test]
    fn a_leftover_config_root_alone_is_not_a_detection() {
        let home = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(home.path().join(".config").join("opencode")).unwrap();
        assert!(!installed_in(home.path(), None).any());
    }

    #[test]
    fn the_install_script_target_is_a_detection() {
        let home = tempfile::tempdir().unwrap();
        let bin = home.path().join(".opencode").join("bin");
        std::fs::create_dir_all(&bin).unwrap();
        let name = if cfg!(windows) {
            "opencode.exe"
        } else {
            "opencode"
        };
        std::fs::write(bin.join(name), "").unwrap();
        assert!(installed_in(home.path(), None).cli.is_some());
    }

    #[test]
    fn a_cli_on_path_is_a_detection() {
        let home = tempfile::tempdir().unwrap();
        let dir = tempfile::tempdir().unwrap();
        let name = if cfg!(windows) {
            "opencode.exe"
        } else {
            "opencode"
        };
        std::fs::write(dir.path().join(name), "").unwrap();
        let path = std::env::join_paths([dir.path()]).unwrap();
        assert_eq!(
            installed_in(home.path(), Some(&path)).cli,
            Some(dir.path().join(name))
        );
    }

    #[test]
    fn versions_parse_and_compare_against_the_floor() {
        assert_eq!(parse_version("1.18.35\n"), Some((1, 18, 35)));
        assert_eq!(parse_version("opencode v1.17.0"), Some((1, 17, 0)));
        assert_eq!(parse_version("0.0.0-dev-202610"), Some((0, 0, 0)));
        assert_eq!(parse_version("garbage"), None);
        assert!(version_supported((1, 17, 0)));
        assert!(version_supported((1, 18, 35)));
        assert!(!version_supported((1, 16, 9)));
    }

    #[cfg(windows)]
    #[test]
    fn desktop_2_0s_windows_install_folder_is_a_detect_location() {
        let expected = dirs::data_local_dir()
            .unwrap()
            .join("Programs")
            .join("@opencodedesktop")
            .join("OpenCode.exe");
        assert!(desktop_locations(Path::new("C:\\Users\\u")).contains(&expected));
    }
}