use std::path::{Path, PathBuf};
use std::time::Duration;
use chrono::{DateTime, TimeZone, Utc};
use crate::daemon::opencode_plugin_seen;
use crate::error::{
ERR_OPENCODE_HOOK_UNREACHABLE, ERR_OPENCODE_PLUGIN_NOT_LOADED, ERR_OPENCODE_RELAY_UNPROVEN,
ERR_OPENCODE_RELAY_UNSUPPORTED, ERR_OPENCODE_SURFACE_UNDETERMINED,
ERR_OPENCODE_VERSION_UNSUPPORTED,
};
use crate::hooks::cline::SurfaceState;
use crate::hooks::cursor_attestation::RelayDelivery;
use crate::hooks::hook_files::{self, Existing};
use crate::hooks::opencode::{self, Installed, Located};
pub const AGENT: &str = "opencode";
const VERSION_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CliReport {
pub state: SurfaceState,
pub version: Option<String>,
pub supported: Option<bool>,
pub prerelease: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DesktopReport {
pub state: SurfaceState,
pub version: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PluginFileState {
Present,
Absent,
Foreign,
}
impl PluginFileState {
pub fn as_str(self) -> &'static str {
match self {
Self::Present => "present",
Self::Absent => "absent",
Self::Foreign => "foreign",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Reachability {
Proven,
Pending,
Failed,
}
impl Reachability {
pub fn as_str(self) -> &'static str {
match self {
Self::Proven => "proven",
Self::Pending => "pending",
Self::Failed => "failed",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpencodeAttestation {
pub as_of: DateTime<Utc>,
pub config_root: Option<(PathBuf, Located)>,
pub cli: CliReport,
pub desktop: DesktopReport,
pub plugin_file: PluginFileState,
pub plugin_seen: bool,
pub last_seen: Option<DateTime<Utc>>,
pub surfaces_seen: Vec<String>,
pub other_plugins: Vec<PathBuf>,
pub managed: Vec<PathBuf>,
pub reachability: Reachability,
pub relay: Option<OpencodeRelay>,
pub bypass_suspected: Option<bool>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WebsocketMode {
Off,
DeveloperOn,
Forced,
}
impl WebsocketMode {
pub fn as_str(self) -> &'static str {
match self {
Self::Off => "off",
Self::DeveloperOn => "developer_on",
Self::Forced => "forced",
}
}
pub fn from_facts(developer_value: Option<&str>, prerelease: bool) -> Self {
if prerelease {
Self::Forced
} else if developer_value.is_some_and(|v| v.trim().eq_ignore_ascii_case("true")) {
Self::DeveloperOn
} else {
Self::Off
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpencodeRelay {
pub cli: RelayDelivery,
pub desktop: RelayDelivery,
pub traffic_proven: bool,
pub last_traffic_ms: Option<i64>,
pub since_daemon_start: bool,
pub websocket: WebsocketMode,
pub tunnelled_hosts: Vec<String>,
pub loopback_hosts: Vec<String>,
pub unwired: Option<(&'static str, String)>,
pub launchers_off_path: bool,
}
pub const OPEN_NEW_TERMINAL_REMEDY: &str = "The OpenLatch launchers are not first on your user \
PATH yet. The daemon puts them there on its next wiring pass (`openlatch doctor --fix` \
restarts it); then open a new terminal so it picks up the OpenLatch launchers on PATH, and \
rerun `openlatch doctor`.";
pub const RELAY_UNPROVEN_REMEDY: &str = "Run one OpenCode turn from a shell that sources \
`env.sh` (PowerShell: `env.ps1`), then rerun `openlatch doctor`.";
impl OpencodeRelay {
pub fn findings(&self) -> Vec<(&'static str, &'static str, &'static str, String)> {
let mut out = Vec::new();
match self.cli {
RelayDelivery::Wired => {}
RelayDelivery::Unsupported => out.push((
"cli",
self.cli.as_str(),
ERR_OPENCODE_RELAY_UNSUPPORTED,
"Nothing to do: this build does not route the OpenCode CLI through the relay \
here; its plugin still enforces."
.to_string(),
)),
RelayDelivery::Unwired => {
let (code, mut remedy) = self.unwired.clone().unwrap_or((
crate::error::ERR_MODEL_RELAY_PREFLIGHT_FAILED,
"Run `openlatch doctor --fix`; the relay wires OpenCode once its preflight \
passes."
.to_string(),
));
if self.launchers_off_path {
remedy = OPEN_NEW_TERMINAL_REMEDY.to_string();
}
out.push(("cli", self.cli.as_str(), code, remedy));
}
}
if self.desktop == RelayDelivery::Unsupported {
out.push((
"desktop",
self.desktop.as_str(),
ERR_OPENCODE_RELAY_UNSUPPORTED,
"Nothing to do: the request plane is not available on OpenCode Desktop in this \
build; its model calls go direct, and the plugin still enforces."
.to_string(),
));
}
if self.cli == RelayDelivery::Wired && !self.traffic_proven {
out.push((
"traffic",
"unproven",
ERR_OPENCODE_RELAY_UNPROVEN,
RELAY_UNPROVEN_REMEDY.to_string(),
));
}
if self.websocket != WebsocketMode::Off {
out.push((
"websocket",
self.websocket.as_str(),
ERR_OPENCODE_RELAY_UNSUPPORTED,
match self.websocket {
WebsocketMode::Forced => {
"This pre-release OpenCode sends OpenAI turns over a WebSocket the relay \
cannot capture. Use a stable OpenCode build."
}
_ => {
"OpenAI turns travel over a WebSocket the relay cannot capture. Unset \
OPENCODE_EXPERIMENTAL_WEBSOCKETS."
}
}
.to_string(),
));
}
out
}
fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"cli": self.cli.as_str(),
"desktop": self.desktop.as_str(),
"traffic": if self.traffic_proven { "proven" } else { "unproven" },
"websocket": self.websocket.as_str(),
"since_daemon_start": self.since_daemon_start,
"tunnelled_hosts": self.tunnelled_hosts,
"loopback_hosts": self.loopback_hosts,
"findings": self.findings().into_iter().map(|(field, state, code, remedy)| {
serde_json::json!({"field": field, "state": state, "code": code, "remedy": remedy})
}).collect::<Vec<_>>(),
})
}
}
pub fn is_prerelease(version_text: &str) -> bool {
version_text
.split_whitespace()
.find(|t| {
t.trim_start_matches('v')
.chars()
.next()
.is_some_and(|c| c.is_ascii_digit())
})
.is_some_and(|t| t.split('+').next().unwrap_or_default().contains('-'))
}
pub fn configured_provider_hosts(
root: Option<&Path>,
cwd: Option<&Path>,
intercepted: &[&str],
) -> (Vec<String>, Vec<String>) {
let (mut tunnelled, mut loopback) = (Vec::new(), Vec::new());
for dir in [root, cwd].into_iter().flatten() {
for name in ["opencode.json", "opencode.jsonc"] {
let Ok(raw) = std::fs::read_to_string(dir.join(name)) else {
continue;
};
let Ok(doc) = crate::hooks::jsonc::parse_settings_value(&raw) else {
continue;
};
let Some(providers) = doc.get("provider").and_then(serde_json::Value::as_object) else {
continue;
};
for provider in providers.values() {
let Some(host) = provider
.pointer("/options/baseURL")
.and_then(serde_json::Value::as_str)
.and_then(url_host)
else {
continue;
};
let list = if is_loopback_host(&host) {
&mut loopback
} else if intercepted.iter().any(|h| h.eq_ignore_ascii_case(&host)) {
continue;
} else {
&mut tunnelled
};
if !list.contains(&host) {
list.push(host);
}
}
}
}
tunnelled.sort();
loopback.sort();
(tunnelled, loopback)
}
fn url_host(url: &str) -> Option<String> {
let rest = url.split_once("://")?.1;
let authority = &rest[..rest.find(['/', '\\', '?', '#']).unwrap_or(rest.len())];
let host_port = authority.rsplit('@').next()?;
let host = if let Some(v6) = host_port.strip_prefix('[') {
v6.split(']').next()?
} else {
host_port.split(':').next()?
};
(!host.is_empty() && !host.contains(['{', '}', ' '])).then(|| host.to_ascii_lowercase())
}
fn is_loopback_host(host: &str) -> bool {
host == "localhost"
|| host.ends_with(".localhost")
|| host == "::1"
|| host
.parse::<std::net::Ipv4Addr>()
.is_ok_and(|ip| ip.is_loopback())
}
pub struct Inputs {
pub openlatch_dir: PathBuf,
pub root: Option<(PathBuf, Located)>,
pub installed: Installed,
pub cli_version: Option<Result<String, String>>,
pub desktop_version: Option<String>,
pub managed_roots: Vec<PathBuf>,
pub now: DateTime<Utc>,
}
pub fn probe(openlatch_dir: &Path) -> OpencodeAttestation {
let installed = opencode::installed();
let cli_version = installed.cli.as_deref().map(run_version);
let desktop_version = installed.desktop.as_deref().and_then(desktop_version);
from_inputs(Inputs {
openlatch_dir: openlatch_dir.to_path_buf(),
root: opencode::root_located(),
installed,
cli_version,
desktop_version,
managed_roots: managed_roots(),
now: Utc::now(),
})
}
pub fn from_inputs(inputs: Inputs) -> OpencodeAttestation {
let cli = match (&inputs.installed.cli, &inputs.cli_version) {
(None, _) => CliReport {
state: if inputs.root.is_some() {
SurfaceState::Absent
} else {
SurfaceState::Undetermined
},
version: None,
supported: None,
prerelease: false,
},
(Some(_), Some(Ok(text))) => match opencode::parse_version(text) {
Some(version) => CliReport {
state: SurfaceState::Present,
version: Some(format!("{}.{}.{}", version.0, version.1, version.2)),
supported: Some(opencode::version_supported(version)),
prerelease: is_prerelease(text),
},
None => CliReport {
state: SurfaceState::Undetermined,
version: None,
supported: None,
prerelease: false,
},
},
(Some(_), _) => CliReport {
state: SurfaceState::Undetermined,
version: None,
supported: None,
prerelease: false,
},
};
let desktop = DesktopReport {
state: match (&inputs.installed.desktop, &inputs.root) {
(Some(_), _) => SurfaceState::Present,
(None, Some(_)) => SurfaceState::Absent,
(None, None) => SurfaceState::Undetermined,
},
version: inputs.desktop_version.clone(),
};
let plugin_path = inputs
.root
.as_ref()
.map(|(root, _)| opencode::plugin_path(root));
let plugin_file = match plugin_path.as_deref().map(hook_files::whatever_is_there) {
Some(Existing::Ours) => PluginFileState::Present,
Some(Existing::Theirs) => PluginFileState::Foreign,
_ => PluginFileState::Absent,
};
let written_ms = plugin_path
.as_deref()
.filter(|_| plugin_file == PluginFileState::Present)
.and_then(opencode_plugin_seen::mtime_ms);
let seen = opencode_plugin_seen::read_seen(&inputs.openlatch_dir);
let plugin_seen =
written_ms.is_some_and(|written| opencode_plugin_seen::seen_since(&seen, written));
let last_seen =
opencode_plugin_seen::last_seen(&seen).and_then(|ms| Utc.timestamp_millis_opt(ms).single());
let installed_at = written_ms.and_then(|ms| Utc.timestamp_millis_opt(ms).single());
let other_plugins = match opencode_plugin_seen::reported_plugins(&inputs.openlatch_dir) {
Some(reported) => reported_other_plugins(&reported, plugin_path.as_deref()),
None => inputs
.root
.as_ref()
.map(|(root, _)| other_plugins(root, plugin_path.as_deref()))
.unwrap_or_default(),
};
OpencodeAttestation {
as_of: inputs.now,
config_root: inputs.root.clone(),
cli,
desktop,
plugin_file,
plugin_seen,
last_seen,
surfaces_seen: seen.keys().cloned().collect(),
other_plugins,
managed: inputs
.managed_roots
.into_iter()
.filter(|p| p.exists())
.collect(),
reachability: reachability(&inputs.openlatch_dir, installed_at),
relay: None,
bypass_suspected: None,
}
}
fn run_version(binary: &Path) -> Result<String, String> {
run_version_within(binary, VERSION_TIMEOUT)
}
fn run_version_within(binary: &Path, timeout: Duration) -> Result<String, String> {
use std::io::BufRead;
let mut child = std::process::Command::new(binary)
.arg("--version")
.env("OPENCODE_DISABLE_AUTOUPDATE", "1")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.map_err(|e| e.to_string())?;
let deadline = std::time::Instant::now() + timeout;
let (tx, rx) = std::sync::mpsc::channel();
if let Some(stdout) = child.stdout.take() {
std::thread::spawn(move || {
let mut line = String::new();
let _ = std::io::BufReader::new(stdout).read_line(&mut line);
let _ = tx.send(line);
});
}
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if std::time::Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(25));
}
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
return Err("`opencode --version` did not answer in time".to_string());
}
Err(e) => return Err(e.to_string()),
}
};
if !status.success() {
return Err(format!("`opencode --version` exited with {status}"));
}
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
rx.recv_timeout(remaining)
.map_err(|_| "`opencode --version` did not answer in time".to_string())
}
fn desktop_version(location: &Path) -> Option<String> {
let plist = std::fs::read_to_string(location.join("Contents").join("Info.plist")).ok()?;
let after_key = plist
.split("<key>CFBundleShortVersionString</key>")
.nth(1)?;
let value = after_key
.split("<string>")
.nth(1)?
.split("</string>")
.next()?;
let value = value.trim();
(!value.is_empty()).then(|| value.to_string())
}
fn managed_roots() -> Vec<PathBuf> {
opencode::managed_dir().into_iter().collect()
}
fn reported_other_plugins(reported: &[String], ours: Option<&Path>) -> Vec<PathBuf> {
let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
let ours = ours.map(canonical);
reported
.iter()
.map(|entry| file_url_path(entry).unwrap_or_else(|| PathBuf::from(entry)))
.filter(|path| ours.as_deref() != Some(canonical(path).as_path()))
.collect()
}
fn file_url_path(entry: &str) -> Option<PathBuf> {
let rest = entry.strip_prefix("file://")?;
let rest = match rest.as_bytes() {
[b'/', drive, b':', ..] if drive.is_ascii_alphabetic() && cfg!(windows) => &rest[1..],
_ => rest,
};
let bytes = rest.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok();
if let Some(byte) = hex.and_then(|h| u8::from_str_radix(h, 16).ok()) {
out.push(byte);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
Some(PathBuf::from(String::from_utf8_lossy(&out).into_owned()))
}
fn other_plugins(root: &Path, ours: Option<&Path>) -> Vec<PathBuf> {
let mut out: Vec<PathBuf> = ["plugin", opencode::PLUGINS_DIR_NAME]
.iter()
.filter_map(|dir| std::fs::read_dir(root.join(dir)).ok())
.flatten()
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| matches!(path.extension().and_then(|e| e.to_str()), Some("js" | "ts")))
.filter(|path| Some(path.as_path()) != ours)
.collect();
out.sort();
out
}
fn reachability(openlatch_dir: &Path, installed_at: Option<DateTime<Utc>>) -> Reachability {
let since = |at: &DateTime<Utc>| installed_at.is_none_or(|installed| *at >= installed);
if crate::daemon::agent_liveness::read(openlatch_dir)
.get(AGENT)
.is_some_and(since)
{
return Reachability::Proven;
}
if fallback_holds_event(&openlatch_dir.join("logs").join("fallback.jsonl"), &since) {
return Reachability::Failed;
}
Reachability::Pending
}
fn fallback_holds_event(path: &Path, since: &dyn Fn(&DateTime<Utc>) -> bool) -> bool {
use std::io::BufRead;
let Ok(file) = std::fs::File::open(path) else {
return false;
};
std::io::BufReader::new(file)
.lines()
.map_while(Result::ok)
.filter(|line| line.contains(AGENT))
.filter_map(|line| serde_json::from_str::<serde_json::Value>(&line).ok())
.filter(|event| event["source"].as_str() == Some(AGENT))
.filter(|event| event["data"]["opencode_hook"].as_str() != Some("plugin_loaded"))
.any(|event| {
event["time"]
.as_str()
.and_then(|t| DateTime::parse_from_rfc3339(t).ok())
.is_some_and(|at| since(&at.with_timezone(&Utc)))
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AbsentSurface {
pub surface: &'static str,
pub state: SurfaceState,
pub code: Option<&'static str>,
pub remedy: String,
}
impl OpencodeAttestation {
pub fn surfaces_absent(&self) -> Vec<AbsentSurface> {
[("cli", self.cli.state), ("desktop", self.desktop.state)]
.into_iter()
.filter(|(_, state)| *state != SurfaceState::Present)
.map(|(surface, state)| match state {
SurfaceState::Undetermined => AbsentSurface {
surface,
state,
code: Some(ERR_OPENCODE_SURFACE_UNDETERMINED),
remedy: format!(
"Start OpenCode once on the {surface} surface, then rerun `openlatch doctor`."
),
},
_ => AbsentSurface {
surface,
state,
code: None,
remedy: format!(
"Nothing to do unless you use the OpenCode {surface}; install it, then rerun `openlatch init --agent opencode`."
),
},
})
.collect()
}
pub fn to_json(&self) -> serde_json::Value {
use serde_json::json;
json!({
"as_of": self.as_of.to_rfc3339(),
"config_root": self.config_root.as_ref().map(|(path, located)| json!({
"path": crate::core::path_compat::display_path(path),
"located": located.as_str(),
})),
"cli": {
"state": self.cli.state.as_str(),
"version": self.cli.version,
"supported": self.cli.supported,
},
"desktop": {
"state": self.desktop.state.as_str(),
"version": self.desktop.version,
},
"plugin_file": {
"state": self.plugin_file.as_str(),
"ours": self.plugin_file == PluginFileState::Present,
},
"plugin_seen": {
"state": if self.plugin_seen { "seen" } else { "pending" },
"last_seen": self.last_seen.map(|t| t.to_rfc3339()),
"surfaces": self.surfaces_seen,
},
"other_plugins": self.other_plugins.iter().map(|path| json!({
"name": path.file_name().map(|n| n.to_string_lossy().into_owned()),
"path": crate::core::path_compat::display_path(path),
})).collect::<Vec<_>>(),
"managed": self.managed.iter().map(|p| crate::core::path_compat::display_path(p)).collect::<Vec<_>>(),
"reachability": self.reachability.as_str(),
"surfaces_absent": self.surfaces_absent().iter().map(|s| json!({
"surface": s.surface,
"state": s.state.as_str(),
"code": s.code,
"remedy": s.remedy,
})).collect::<Vec<_>>(),
"relay": self.relay.as_ref().map(OpencodeRelay::to_json),
"bypass_suspected": self.bypass_suspected,
})
}
pub fn summary(&self) -> String {
let cli = match (&self.cli.state, &self.cli.version) {
(SurfaceState::Present, Some(v)) => format!("CLI {v}"),
(state, _) => format!("CLI {}", state.as_str()),
};
let desktop = match (&self.desktop.state, &self.desktop.version) {
(SurfaceState::Present, Some(v)) => format!("Desktop {v}"),
(state, _) => format!("Desktop {}", state.as_str()),
};
format!(
"OpenCode: {cli}, {desktop}; plugin {} ({})",
self.plugin_file.as_str(),
if self.plugin_seen { "seen" } else { "pending" }
)
}
pub fn human_lines(&self) -> Vec<String> {
let mut lines = Vec::new();
if let Some((path, located)) = &self.config_root {
lines.push(format!(
"Config root: {} ({})",
crate::core::path_compat::display_path(path),
located.as_str()
));
}
if let Some(at) = self.last_seen {
lines.push(format!(
"Plugin last loaded: {} ({})",
at.to_rfc3339(),
self.surfaces_seen.join(", ")
));
}
for path in &self.other_plugins {
lines.push(format!(
"Other plugin: {}",
crate::core::path_compat::display_path(path)
));
}
for path in &self.managed {
lines.push(format!(
"Managed config (read only): {}",
crate::core::path_compat::display_path(path)
));
}
lines.push(format!("Reachability: {}", self.reachability.as_str()));
if let Some(relay) = &self.relay {
lines.push(format!(
"Relay: CLI {}, Desktop {}, traffic {}, WebSocket {}{}",
relay.cli.as_str(),
relay.desktop.as_str(),
if relay.traffic_proven {
"proven"
} else {
"unproven"
},
relay.websocket.as_str(),
if relay.since_daemon_start {
" (since daemon start)"
} else {
""
}
));
for host in &relay.tunnelled_hosts {
lines.push(format!(
"Custom provider host, tunnelled and not captured: {host}"
));
}
for host in &relay.loopback_hosts {
lines.push(format!("Loopback provider, not captured: {host}"));
}
}
lines
}
pub fn not_loaded(&self, written_ms: Option<i64>) -> Option<(&'static str, String)> {
let relay = self.relay.as_ref()?;
let traffic_since_write = !self.plugin_seen
&& self.plugin_file == PluginFileState::Present
&& relay
.last_traffic_ms
.zip(written_ms)
.is_some_and(|(traffic, written)| traffic >= written);
let signal = if self.bypass_suspected == Some(true) {
"the relay recorded OpenCode model traffic for a session the plugin never reported \
(bypass suspected)"
} else if traffic_since_write {
"the relay recorded OpenCode model traffic since the plugin was written, and the \
plugin has not reported loading"
} else {
return None;
};
Some((
ERR_OPENCODE_PLUGIN_NOT_LOADED,
format!("{signal}. Restart OpenCode and check that it is not started with `--pure`."),
))
}
pub fn version_unsupported(&self) -> Option<(&'static str, String)> {
(self.cli.supported == Some(false)).then(|| {
(
ERR_OPENCODE_VERSION_UNSUPPORTED,
format!(
"OpenCode {} is below the supported floor 1.17.0. Run `opencode upgrade`.",
self.cli.version.as_deref().unwrap_or("unknown")
),
)
})
}
pub fn unreachable(&self) -> Option<(&'static str, String)> {
(self.reachability == Reachability::Failed).then(|| {
let likely = if cfg!(windows) {
"on Windows, check that `openlatch-hook.exe` exists under the OpenLatch `bin` folder and is not blocked"
} else {
"check that the daemon is running and that `openlatch-hook` exists under the OpenLatch `bin` folder"
};
(
ERR_OPENCODE_HOOK_UNREACHABLE,
format!(
"The OpenLatch plugin ran the hook but it could not reach the daemon; {likely}. Run `openlatch doctor --fix`, then restart OpenCode."
),
)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
fn script(dir: &Path, name: &str, body: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = dir.join(name);
std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}
#[cfg(unix)]
#[test]
fn the_version_probe_is_bounded_when_a_descendant_holds_stdout() {
let dir = tempfile::tempdir().unwrap();
let answers = script(dir.path(), "answers", "(sleep 30) &\necho 1.18.35");
let started = std::time::Instant::now();
let out = run_version_within(&answers, Duration::from_secs(3)).expect("version");
assert_eq!(out.trim(), "1.18.35");
assert!(started.elapsed() < Duration::from_secs(3));
let silent = script(dir.path(), "silent", "(sleep 30) &\nexit 0");
let started = std::time::Instant::now();
assert!(run_version_within(&silent, Duration::from_millis(500)).is_err());
assert!(started.elapsed() < Duration::from_secs(3));
}
struct Host {
_dir: tempfile::TempDir,
ol: PathBuf,
root: PathBuf,
}
fn host() -> Host {
let dir = tempfile::tempdir().expect("tempdir");
let ol = dir.path().join("openlatch");
let root = dir.path().join("config").join("opencode");
std::fs::create_dir_all(&ol).unwrap();
std::fs::create_dir_all(&root).unwrap();
Host {
ol,
root,
_dir: dir,
}
}
fn inputs(host: &Host, cli_version: Option<Result<String, String>>) -> Inputs {
Inputs {
openlatch_dir: host.ol.clone(),
root: Some((host.root.clone(), Located::Xdg)),
installed: Installed {
cli: cli_version
.as_ref()
.map(|_| PathBuf::from("/usr/local/bin/opencode")),
desktop: None,
},
cli_version,
desktop_version: None,
managed_roots: vec![],
now: Utc::now(),
}
}
#[test]
fn other_plugins_follow_the_load_report_then_the_folders() {
let host = host();
let plugins = opencode::plugins_dir(&host.root);
crate::hooks::opencode_plugin::install(&plugins, &host.ol).expect("install");
std::fs::write(plugins.join("global.js"), "export default async () => ({})").unwrap();
let before = from_inputs(inputs(&host, None));
assert_eq!(before.other_plugins, vec![plugins.join("global.js")]);
let ours = format!("file://{}", opencode::plugin_path(&host.root).display());
let repo = std::env::temp_dir().join("repo with space/.opencode/plugins/repo.ts");
let repo_url = format!("file://{}", repo.display()).replace(' ', "%20");
crate::daemon::opencode_plugin_seen::record_report(
&host.ol,
"cli",
1,
Some(&["opencode-foo@1.0.0".to_string(), repo_url, ours]),
)
.unwrap();
let after = from_inputs(inputs(&host, None));
assert_eq!(
after.other_plugins,
vec![PathBuf::from("opencode-foo@1.0.0"), repo]
);
}
#[test]
fn pending_until_a_load_report_follows_the_write_then_seen() {
let host = host();
crate::hooks::opencode_plugin::install(&opencode::plugins_dir(&host.root), &host.ol)
.expect("install");
let a = from_inputs(inputs(&host, Some(Ok("1.18.35\n".into()))));
assert_eq!(a.plugin_file, PluginFileState::Present);
assert!(!a.plugin_seen);
let json = a.to_json();
assert_eq!(json["plugin_seen"]["state"], "pending");
assert_eq!(json["plugin_file"]["ours"], true);
assert_eq!(json["cli"]["version"], "1.18.35");
assert_eq!(json["cli"]["supported"], true);
assert_eq!(json["config_root"]["located"], "xdg");
assert_eq!(json["reachability"], "pending");
let later = Utc::now().timestamp_millis() + 1000;
opencode_plugin_seen::record(&host.ol, "cli", later).unwrap();
let a = from_inputs(inputs(&host, Some(Ok("1.18.35".into()))));
assert!(a.plugin_seen);
assert_eq!(a.to_json()["plugin_seen"]["state"], "seen");
assert_eq!(
a.to_json()["plugin_seen"]["surfaces"],
serde_json::json!(["cli"])
);
}
#[test]
fn every_c5_field_is_rendered() {
let host = host();
let json = from_inputs(inputs(&host, Some(Ok("1.18.35".into())))).to_json();
for field in [
"as_of",
"config_root",
"cli",
"desktop",
"plugin_file",
"plugin_seen",
"other_plugins",
"reachability",
"surfaces_absent",
"relay",
"bypass_suspected",
] {
assert!(json.get(field).is_some(), "{field} missing");
}
assert!(json["relay"].is_null(), "filled by doctor from the relay");
assert!(json["bypass_suspected"].is_null());
assert_eq!(json["plugin_file"]["state"], "absent");
}
#[test]
fn a_cli_below_the_floor_is_unsupported() {
let host = host();
let a = from_inputs(inputs(&host, Some(Ok("opencode 1.16.2".into()))));
assert_eq!(a.cli.supported, Some(false));
let (code, remedy) = a.version_unsupported().expect("finding");
assert_eq!(code, ERR_OPENCODE_VERSION_UNSUPPORTED);
assert!(remedy.contains("opencode upgrade"));
assert!(from_inputs(inputs(&host, Some(Ok("1.17.0".into()))))
.version_unsupported()
.is_none());
}
#[test]
fn an_unanswering_cli_and_an_unresolved_root_are_undetermined() {
let host = host();
let a = from_inputs(inputs(&host, Some(Err("timed out".into()))));
assert_eq!(a.cli.state, SurfaceState::Undetermined);
let absent = a.surfaces_absent();
let cli = absent.iter().find(|s| s.surface == "cli").expect("cli");
assert_eq!(cli.code, Some(ERR_OPENCODE_SURFACE_UNDETERMINED));
let desktop = absent
.iter()
.find(|s| s.surface == "desktop")
.expect("desktop");
assert_eq!(desktop.state, SurfaceState::Absent);
assert_eq!(desktop.code, None);
let mut no_root = inputs(&host, None);
no_root.root = None;
let a = from_inputs(no_root);
assert_eq!(a.cli.state, SurfaceState::Undetermined);
assert_eq!(a.desktop.state, SurfaceState::Undetermined);
}
#[test]
fn a_foreign_file_and_other_plugins_are_named() {
let host = host();
let plugins = opencode::plugins_dir(&host.root);
std::fs::create_dir_all(&plugins).unwrap();
std::fs::write(
plugins.join("openlatch.js"),
"export default async () => ({})\n",
)
.unwrap();
std::fs::write(plugins.join("other.ts"), "x").unwrap();
std::fs::write(plugins.join("notes.md"), "x").unwrap();
std::fs::create_dir_all(host.root.join("plugin")).unwrap();
std::fs::write(host.root.join("plugin").join("legacy.js"), "x").unwrap();
let a = from_inputs(inputs(&host, None));
assert_eq!(a.plugin_file, PluginFileState::Foreign);
assert_eq!(a.to_json()["plugin_file"]["ours"], false);
let names: Vec<_> = a
.other_plugins
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
assert_eq!(names, ["legacy.js", "other.ts"]);
}
#[test]
fn reachability_is_proven_by_a_live_event_and_failed_by_fallback_only() {
let host = host();
crate::hooks::opencode_plugin::install(&opencode::plugins_dir(&host.root), &host.ol)
.expect("install");
let logs = host.ol.join("logs");
std::fs::create_dir_all(&logs).unwrap();
let future = (Utc::now() + chrono::Duration::minutes(5)).to_rfc3339();
std::fs::write(
logs.join("fallback.jsonl"),
format!(
"{}\n",
serde_json::json!({"source": "opencode", "time": future, "data": {"opencode_hook": "plugin_loaded"}})
),
)
.unwrap();
assert_eq!(
from_inputs(inputs(&host, None)).reachability,
Reachability::Pending
);
std::fs::write(
logs.join("fallback.jsonl"),
format!(
"{}\n",
serde_json::json!({"source": "opencode", "time": future, "data": {"opencode_hook": "tool.execute.before"}})
),
)
.unwrap();
let a = from_inputs(inputs(&host, None));
assert_eq!(a.reachability, Reachability::Failed);
assert_eq!(
a.unreachable().map(|(c, _)| c),
Some(ERR_OPENCODE_HOOK_UNREACHABLE)
);
std::fs::write(
host.ol.join(crate::daemon::agent_liveness::FILE_NAME),
serde_json::json!({"opencode": future}).to_string(),
)
.unwrap();
assert_eq!(
from_inputs(inputs(&host, None)).reachability,
Reachability::Proven
);
}
#[test]
fn the_desktop_version_is_read_from_the_bundle_metadata() {
let dir = tempfile::tempdir().unwrap();
let app = dir.path().join("OpenCode.app");
std::fs::create_dir_all(app.join("Contents")).unwrap();
std::fs::write(
app.join("Contents").join("Info.plist"),
"<plist><dict><key>CFBundleName</key><string>OpenCode</string>\
<key>CFBundleShortVersionString</key>\n<string>1.18.35</string></dict></plist>",
)
.unwrap();
assert_eq!(desktop_version(&app).as_deref(), Some("1.18.35"));
assert_eq!(desktop_version(dir.path()), None);
}
fn relay(cli: RelayDelivery, traffic: bool, websocket: WebsocketMode) -> OpencodeRelay {
OpencodeRelay {
cli,
desktop: RelayDelivery::Unsupported,
traffic_proven: traffic,
last_traffic_ms: traffic.then_some(2_000),
since_daemon_start: true,
websocket,
tunnelled_hosts: vec!["llm.corp.test".into()],
loopback_hosts: vec!["localhost".into()],
unwired: None,
launchers_off_path: false,
}
}
#[test]
fn the_websocket_transport_is_read_from_the_channel_and_the_environment() {
assert!(is_prerelease("0.0.0-dev-202610092229"));
assert!(is_prerelease("opencode 0.0.0-beta-202608110357"));
assert!(!is_prerelease("1.18.35"));
assert!(!is_prerelease("v1.18.35+build.7"));
assert!(!is_prerelease(""));
assert_eq!(WebsocketMode::from_facts(None, false), WebsocketMode::Off);
assert_eq!(
WebsocketMode::from_facts(Some(""), false),
WebsocketMode::Off
);
assert_eq!(
WebsocketMode::from_facts(Some("false"), false),
WebsocketMode::Off
);
assert_eq!(
WebsocketMode::from_facts(Some("TRUE"), false),
WebsocketMode::DeveloperOn
);
assert_eq!(
WebsocketMode::from_facts(Some("false"), true),
WebsocketMode::Forced
);
}
#[test]
fn the_relay_findings_carry_their_codes() {
let codes = |r: &OpencodeRelay| -> Vec<(&str, &str)> {
r.findings()
.into_iter()
.map(|(f, _, c, _)| (f, c))
.collect()
};
let green = relay(RelayDelivery::Wired, true, WebsocketMode::Off);
assert_eq!(
codes(&green),
vec![("desktop", ERR_OPENCODE_RELAY_UNSUPPORTED)],
"Desktop has no delivery in this build (D-13)"
);
let unproven = relay(RelayDelivery::Wired, false, WebsocketMode::Off);
assert!(codes(&unproven).contains(&("traffic", ERR_OPENCODE_RELAY_UNPROVEN)));
for mode in [WebsocketMode::DeveloperOn, WebsocketMode::Forced] {
assert!(codes(&relay(RelayDelivery::Wired, true, mode))
.contains(&("websocket", ERR_OPENCODE_RELAY_UNSUPPORTED)));
}
let off_path = OpencodeRelay {
launchers_off_path: true,
..relay(RelayDelivery::Unwired, false, WebsocketMode::Off)
};
let cli = off_path
.findings()
.into_iter()
.find(|(f, ..)| *f == "cli")
.expect("a cli finding");
assert_eq!(cli.1, "unwired");
assert_eq!(
cli.3, OPEN_NEW_TERMINAL_REMEDY,
"Windows: the launchers are not first on PATH"
);
let unwired = relay(RelayDelivery::Unwired, false, WebsocketMode::Off);
assert!(
!codes(&unwired).iter().any(|(f, _)| *f == "traffic"),
"an unwired CLI is the finding, not its traffic"
);
assert!(green
.findings()
.iter()
.chain(unproven.findings().iter())
.all(|(_, _, _, remedy)| !remedy.is_empty()));
}
#[test]
fn not_loaded_fires_on_traffic_since_the_write_or_a_suspected_bypass() {
let host = host();
let plugins = opencode::plugins_dir(&host.root);
crate::hooks::opencode_plugin::install(&plugins, &host.ol).expect("install");
let mut att = from_inputs(inputs(&host, None));
assert!(!att.plugin_seen);
assert!(att.not_loaded(Some(1_000)).is_none(), "no relay, no signal");
att.relay = Some(relay(RelayDelivery::Wired, true, WebsocketMode::Off));
let (code, detail) = att.not_loaded(Some(1_000)).expect("traffic since write");
assert_eq!(code, ERR_OPENCODE_PLUGIN_NOT_LOADED);
assert!(detail.contains("--pure"));
assert!(
att.not_loaded(Some(5_000)).is_none(),
"traffic before the write proves nothing"
);
att.relay = Some(relay(RelayDelivery::Wired, false, WebsocketMode::Off));
assert!(att.not_loaded(Some(1_000)).is_none());
att.bypass_suspected = Some(true);
let (_, detail) = att.not_loaded(Some(1_000)).expect("bypass");
assert!(detail.contains("bypass suspected"));
att.bypass_suspected = Some(false);
att.plugin_seen = true;
att.relay = Some(relay(RelayDelivery::Wired, true, WebsocketMode::Off));
assert!(att.not_loaded(Some(1_000)).is_none());
}
#[test]
fn the_json_carries_relay_and_bypass_suspected() {
let host = host();
let mut att = from_inputs(inputs(&host, Some(Ok("1.18.35".into()))));
let json = att.to_json();
assert!(json["relay"].is_null());
assert!(json["bypass_suspected"].is_null());
att.relay = Some(relay(RelayDelivery::Wired, true, WebsocketMode::Off));
att.bypass_suspected = Some(false);
let json = att.to_json();
assert_eq!(json["relay"]["cli"], "wired");
assert_eq!(json["relay"]["desktop"], "unsupported");
assert_eq!(json["relay"]["traffic"], "proven");
assert_eq!(json["relay"]["websocket"], "off");
assert_eq!(
json["relay"]["tunnelled_hosts"],
serde_json::json!(["llm.corp.test"])
);
assert_eq!(
json["relay"]["loopback_hosts"],
serde_json::json!(["localhost"])
);
assert_eq!(json["bypass_suspected"], false);
assert!(att
.human_lines()
.iter()
.any(|l| l.contains("tunnelled and not captured: llm.corp.test")));
}
#[test]
fn configured_provider_hosts_split_tunnelled_from_loopback() {
let dir = tempfile::tempdir().unwrap();
let (root, repo) = (dir.path().join("root"), dir.path().join("repo"));
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&repo).unwrap();
std::fs::write(
root.join("opencode.json"),
r#"{"provider":{
"corp":{"options":{"baseURL":"https://user:pw@LLM.Corp.test:8443/v1?k=SECRET","apiKey":"SENTINEL_KEY"}},
"anthropic":{"options":{"baseURL":"https://api.anthropic.com/v1"}},
"ollama":{"options":{"baseURL":"http://127.0.0.1:11434/v1"}}
}}"#,
)
.unwrap();
std::fs::write(
repo.join("opencode.jsonc"),
"// repo config\n{\"provider\":{\"gw\":{\"options\":{\"baseURL\":\"https://gw.example.com/openai\"}},\"lm\":{\"options\":{\"baseURL\":\"http://localhost:1234/v1\"}}}}",
)
.unwrap();
std::fs::write(repo.join("opencode.json"), "{ not json").unwrap();
let (tunnelled, loopback) = configured_provider_hosts(
Some(&root),
Some(&repo),
crate::hooks::bindings::opencode::OPENCODE_INTERCEPT_HOSTS,
);
assert_eq!(tunnelled, vec!["gw.example.com", "llm.corp.test"]);
assert_eq!(loopback, vec!["127.0.0.1", "localhost"]);
let all = format!("{tunnelled:?}{loopback:?}");
for leak in ["SENTINEL_KEY", "SECRET", "pw", "8443", "/v1"] {
assert!(!all.contains(leak), "{leak}");
}
}
}