use std::collections::BTreeMap;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
use sha2::{Digest, Sha256};
use crate::core::fs_secure;
use crate::hooks::cline_bootstrap;
use crate::hooks::cline_hosts::{Host, HostClass};
use crate::hooks::hook_files::sha256_bytes;
pub const LAUNCHER_BIN: &str = "openlatch-cline-runtime";
const LAUNCHER_BIN_EXE: &str = "openlatch-cline-runtime.exe";
pub fn launcher_file_name(windows: bool) -> &'static str {
if windows {
LAUNCHER_BIN_EXE
} else {
LAUNCHER_BIN
}
}
const MARKER: &str = "# openlatch-cline-runtime";
const PROBE_TIMEOUT: Duration = Duration::from_secs(8);
const STDERR_TAIL: usize = 2048;
const PROBE_CACHE_FILE: &str = "cline-plugin-probe.json";
const PROBE_HOME_DIR: &str = "cline-probe-home";
const WINDOWS_ARM_PROVEN: bool = true;
pub fn windows_arm_proven() -> bool {
WINDOWS_ARM_PROVEN
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WrapperError {
LauncherMissing,
RuntimeMissing,
WindowsUnproven,
Io(String),
}
impl std::fmt::Display for WrapperError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WrapperError::LauncherMissing => {
write!(f, "{LAUNCHER_BIN} is missing beside openlatch")
}
WrapperError::RuntimeMissing => write!(f, "the host's runtime binary is missing"),
WrapperError::WindowsUnproven => write!(f, "the Windows runtime launcher is unproven"),
WrapperError::Io(e) => write!(f, "{e}"),
}
}
}
pub fn wrapper_for(ol_dir: &Path, class: HostClass) -> PathBuf {
ol_dir
.join("bin")
.join(wrapper_file_name(class, cfg!(windows)))
}
pub fn wrapper_file_name(class: HostClass, windows: bool) -> String {
let name = format!("cline-js-runtime-{}", class.as_str());
if windows {
format!("{name}.exe")
} else {
name
}
}
fn target_file_for(wrapper: &Path) -> PathBuf {
wrapper.with_extension("target")
}
pub(crate) fn wrapper_body(runtime: &Path, var: &str) -> String {
let exec = format!(
"{var}=1 exec {} \"$@\"\n",
crate::hooks::hook_files::quote_posix(&runtime.to_string_lossy())
);
format!(
"#!/bin/sh\n{MARKER} {}\n{exec}",
sha256_bytes(exec.as_bytes())
)
}
pub(crate) fn target_body(runtime: &Path, var: &str) -> String {
format!("runtime={}\nenv={var}=1\n", runtime.to_string_lossy())
}
pub fn write_wrapper(ol_dir: &Path, host: &Host) -> Result<PathBuf, WrapperError> {
if host.class == HostClass::Cli {
return Err(WrapperError::Io(
"the Cline CLI ships its own runtime; it gets no wrapper".to_string(),
));
}
if !host.runtime.is_file() {
return Err(WrapperError::RuntimeMissing);
}
let wrapper = wrapper_for(ol_dir, host.class);
let bin = wrapper
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| ol_dir.join("bin"));
std::fs::create_dir_all(&bin).map_err(|e| WrapperError::Io(e.to_string()))?;
if cfg!(windows) {
if !windows_arm_proven() {
return Err(WrapperError::WindowsUnproven);
}
let source = std::env::current_exe()
.map(|exe| crate::core::update::launcher_beside(&exe))
.map_err(|_| WrapperError::LauncherMissing)?;
stage_launcher(&source, &wrapper)?;
write_if_differs(
&target_file_for(&wrapper),
target_body(&host.runtime, host.runtime_var).as_bytes(),
false,
)?;
return Ok(wrapper);
}
write_if_differs(
&wrapper,
wrapper_body(&host.runtime, host.runtime_var).as_bytes(),
true,
)?;
Ok(wrapper)
}
fn write_if_differs(path: &Path, body: &[u8], executable: bool) -> Result<(), WrapperError> {
if std::fs::read(path).is_ok_and(|current| current == body) {
return Ok(());
}
let written = if executable {
fs_secure::write_executable_bytes(path, body)
} else {
fs_secure::write_readable_bytes(path, body)
};
written.map_err(|e| WrapperError::Io(format!("{}: {e}", path.display())))
}
pub(crate) fn stage_launcher(source: &Path, target: &Path) -> Result<(), WrapperError> {
sweep_old(target);
let wanted = match std::fs::read(source) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(WrapperError::LauncherMissing)
}
Err(e) => return Err(WrapperError::Io(format!("{}: {e}", source.display()))),
};
let aside = match std::fs::read(target) {
Ok(current) if current == wanted => return Ok(()),
Ok(_) => {
let aside = (0u32..1000)
.map(|n| old_name(target, n))
.find(|p| std::fs::symlink_metadata(p).is_err())
.ok_or_else(|| WrapperError::Io("too many stale launcher copies".to_string()))?;
std::fs::rename(target, &aside)
.map_err(|e| WrapperError::Io(format!("{}: {e}", target.display())))?;
Some(aside)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(WrapperError::Io(format!("{}: {e}", target.display()))),
};
if let Err(e) = fs_secure::write_executable_bytes(target, &wanted) {
if let Some(aside) = aside {
let _ = std::fs::rename(&aside, target);
}
return Err(WrapperError::Io(format!("{}: {e}", target.display())));
}
Ok(())
}
fn old_name(target: &Path, n: u32) -> PathBuf {
let mut name = target
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_default();
name.push(format!(".old-{n}"));
target.with_file_name(name)
}
fn sweep_old(target: &Path) {
let (Some(dir), Some(name)) = (target.parent(), target.file_name()) else {
return;
};
let prefix = format!("{}.old-", name.to_string_lossy());
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
if entry.file_name().to_string_lossy().starts_with(&prefix) {
let _ = std::fs::remove_file(entry.path());
}
}
}
pub fn remove_wrappers(ol_dir: &Path, keep: &[HostClass]) -> std::io::Result<()> {
for class in [HostClass::Hub, HostClass::VsCode] {
if keep.contains(&class) {
continue;
}
let wrapper = wrapper_for(ol_dir, class);
sweep_old(&wrapper);
for path in [target_file_for(&wrapper), wrapper] {
match std::fs::remove_file(&path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e),
_ => {}
}
}
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ProbeResult {
pub ok: bool,
pub stage: Option<String>,
pub error: Option<String>,
}
impl ProbeResult {
fn failed(stage: &str, error: impl Into<String>) -> ProbeResult {
ProbeResult {
ok: false,
stage: Some(stage.to_string()),
error: Some(error.into()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
struct ProbeRecord {
key: String,
#[serde(flatten)]
result: ProbeResult,
at_ms: i64,
}
fn probe_cache_path(ol_dir: &Path) -> PathBuf {
ol_dir.join("state").join(PROBE_CACHE_FILE)
}
pub fn probe(ol_dir: &Path, host: &Host) -> ProbeResult {
probe_with(ol_dir, host, true)
}
pub fn probe_fresh(ol_dir: &Path, host: &Host) -> ProbeResult {
probe_with(ol_dir, host, false)
}
fn probe_with(ol_dir: &Path, host: &Host, use_cache: bool) -> ProbeResult {
let wrapper = wrapper_for(ol_dir, host.class);
let Some(signature) = cline_bootstrap::expected_signature() else {
return ProbeResult::failed(
"spawn",
cline_bootstrap::TreeProblem::UnsupportedPlatform.to_string(),
);
};
let key = cache_key(host, &wrapper, &signature);
let mut cache = read_probe_cache(ol_dir);
if use_cache {
if let Some(hit) = cache.get(host.class.as_str()).filter(|r| r.key == key) {
return hit.result.clone();
}
}
let tree = match cline_bootstrap::verify(ol_dir) {
Ok(tree) => tree,
Err(problem) => return ProbeResult::failed("spawn", problem.to_string()),
};
let Some(bootstrap) = cline_bootstrap::bootstrap_path(ol_dir) else {
return ProbeResult::failed("spawn", "this platform has no Cline plugin-sandbox package");
};
let result = run_probe(ol_dir, &wrapper, &tree.root, &bootstrap);
cache.insert(
host.class.as_str().to_string(),
ProbeRecord {
key,
result: result.clone(),
at_ms: chrono::Utc::now().timestamp_millis(),
},
);
if let Err(e) = fs_secure::write_json_state(&probe_cache_path(ol_dir), &cache) {
tracing::warn!(error = %e, "could not record the Cline plugin probe result");
}
result
}
fn read_probe_cache(ol_dir: &Path) -> BTreeMap<String, ProbeRecord> {
std::fs::read(probe_cache_path(ol_dir))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
fn cache_key(host: &Host, wrapper: &Path, tree_signature: &str) -> String {
let wrapper_sha = std::fs::read(wrapper)
.map(|b| sha256_bytes(&b))
.unwrap_or_default();
let (len, mtime) = std::fs::metadata(&host.runtime)
.map(|m| {
let mtime = m
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos())
.unwrap_or(0);
(m.len(), mtime)
})
.unwrap_or((0, 0));
let mut hasher = Sha256::new();
for part in [
host.class.as_str().to_string(),
wrapper_sha,
host.runtime.to_string_lossy().into_owned(),
len.to_string(),
mtime.to_string(),
tree_signature.to_string(),
] {
hasher.update(part.as_bytes());
hasher.update([0u8]);
}
hex::encode(hasher.finalize())
}
fn run_probe(ol_dir: &Path, wrapper: &Path, tree: &Path, bootstrap: &Path) -> ProbeResult {
let home = ol_dir.join("state").join(PROBE_HOME_DIR);
let cline_dir = home.join(".cline");
if let Err(e) = std::fs::create_dir_all(&cline_dir) {
return ProbeResult::failed("spawn", format!("cannot create the probe home: {e}"));
}
let mut child = match Command::new(wrapper)
.arg(tree.join(cline_bootstrap::PROBE_HOST))
.arg(bootstrap)
.arg(tree.join(cline_bootstrap::PROBE_PLUGIN_DIR))
.env("OPENLATCH_PROBE_RUNTIME", wrapper)
.env("HOME", &home)
.env("USERPROFILE", &home)
.env("CLINE_DIR", &cline_dir)
.env("CLINE_DATA_DIR", &cline_dir)
.env("CLINE_HUB_PORT", "0")
.env("CLINE_HUB_DISCOVERY_PATH", home.join("hub-discovery.json"))
.current_dir(&home)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(child) => child,
Err(e) => return ProbeResult::failed("spawn", e.to_string()),
};
let stdout = child.stdout.take().map(drain);
let stderr = child.stderr.take().map(drain);
let deadline = Instant::now() + PROBE_TIMEOUT;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break Some(status),
Ok(None) if Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
break None;
}
Ok(None) => std::thread::sleep(Duration::from_millis(50)),
Err(_) => {
let _ = child.kill();
let _ = child.wait();
break None;
}
}
};
let out = stdout.and_then(|h| h.join().ok()).unwrap_or_default();
let err = stderr.and_then(|h| h.join().ok()).unwrap_or_default();
let err_text = String::from_utf8_lossy(&err);
let tail: String = {
let chars: Vec<char> = err_text.chars().collect();
chars[chars.len().saturating_sub(STDERR_TAIL)..]
.iter()
.collect()
};
let Some(status) = status else {
return ProbeResult::failed("timeout", tail);
};
let parsed = String::from_utf8_lossy(&out)
.lines()
.rev()
.find(|l| !l.trim().is_empty())
.and_then(|l| serde_json::from_str::<ProbeResult>(l.trim()).ok());
match parsed {
Some(result) if status.success() => result,
_ => ProbeResult::failed("spawn", tail),
}
}
fn drain<R: Read + Send + 'static>(mut reader: R) -> std::thread::JoinHandle<Vec<u8>> {
std::thread::spawn(move || {
let mut buf = Vec::new();
let _ = reader.read_to_end(&mut buf);
buf
})
}
pub mod reason {
pub const DELIVERY_OFF: &str = "delivery-off";
pub const HOST_TOO_OLD: &str = "host-too-old";
pub const WINDOWS_LAUNCHER_UNPROVEN: &str = "windows-launcher-unproven";
pub const WINDOWS_LAUNCHER_MISSING: &str = "windows-launcher-missing";
pub const RUNTIME_MISSING: &str = "runtime-missing";
pub const PROBE_FAILED: &str = "probe-failed";
pub const NOT_MANAGED: &str = "not-managed";
pub const CUSTOMER_OVERRIDE: &str = "customer-override";
pub const NOT_DETECTED_ON_PLATFORM: &str = "not-detected-on-platform";
pub const PENDING_RESTART: &str = "pending-restart";
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostAssessment {
pub host: Host,
pub wrapper: Option<PathBuf>,
pub reason: Option<String>,
}
impl HostAssessment {
pub fn deliverable(&self) -> bool {
self.reason.is_none()
}
}
pub fn assess_with(
ol_dir: &Path,
host: &Host,
enabled: bool,
tree: &Result<(), String>,
probe: &dyn Fn(&Path, &Host) -> ProbeResult,
) -> HostAssessment {
let wrapper = write_wrapper(ol_dir, host);
let floor = match &host.core_version {
None => Some(format!("{}: core version unreadable", reason::HOST_TOO_OLD)),
Some(v) if !crate::hooks::cline_hosts::meets_floor(v) => Some(format!(
"{}: core {v} is below {}",
reason::HOST_TOO_OLD,
crate::hooks::cline_hosts::HOST_FLOOR
)),
Some(_) => None,
};
let wrapper_reason = wrapper.as_ref().err().map(|e| match e {
WrapperError::WindowsUnproven => reason::WINDOWS_LAUNCHER_UNPROVEN.to_string(),
WrapperError::LauncherMissing => reason::WINDOWS_LAUNCHER_MISSING.to_string(),
WrapperError::RuntimeMissing => reason::RUNTIME_MISSING.to_string(),
WrapperError::Io(d) => format!("{}: wrapper: {d}", reason::PROBE_FAILED),
});
let reason = if !enabled {
Some(reason::DELIVERY_OFF.to_string())
} else if floor.is_some() {
floor
} else if wrapper_reason.is_some() {
wrapper_reason
} else if tree.is_err() {
Some(format!("{}: tree", reason::PROBE_FAILED))
} else if host.class == HostClass::VsCode && !login_env_can_carry(ol_dir, host) {
Some(format!(
"{}: unsupported path characters",
reason::PROBE_FAILED
))
} else {
let result = probe(ol_dir, host);
(!result.ok).then(|| {
format!(
"{}: {}{}",
reason::PROBE_FAILED,
result.stage.as_deref().unwrap_or("probe"),
result
.error
.as_deref()
.map(|e| format!(": {e}"))
.unwrap_or_default()
)
})
};
HostAssessment {
host: host.clone(),
wrapper: wrapper.ok(),
reason,
}
}
fn login_env_can_carry(ol_dir: &Path, host: &Host) -> bool {
[
cline_bootstrap::wrapper_path(ol_dir),
wrapper_for(ol_dir, host.class),
]
.iter()
.all(|p| {
p.to_str()
.is_some_and(crate::core::login_env::value_is_deliverable)
})
}
pub fn assess_hosts(ol_dir: &Path, enabled: bool) -> Vec<HostAssessment> {
assess_hosts_with(ol_dir, enabled, crate::hooks::cline_app::detect())
}
pub(crate) fn assess_hosts_with(
ol_dir: &Path,
enabled: bool,
app: Option<crate::hooks::cline_app::ClineApp>,
) -> Vec<HostAssessment> {
let tree = cline_bootstrap::materialise(ol_dir)
.map(|_| ())
.map_err(|e| e.message);
crate::hooks::cline_hosts::detect_with(ol_dir, app)
.iter()
.map(|host| assess_with(ol_dir, host, enabled, &tree, &probe))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrapper_body_never_exports() {
let runtime = Path::new("/Apps/Visual Studio Code.app/Contents/MacOS/O'Brien Code");
let body = wrapper_body(runtime, "ELECTRON_RUN_AS_NODE");
let lines: Vec<&str> = body.lines().collect();
assert_eq!(lines.len(), 3, "{body}");
assert_eq!(lines[0], "#!/bin/sh");
assert!(lines[1].starts_with("# openlatch-cline-runtime "));
assert_eq!(
lines[2],
r#"ELECTRON_RUN_AS_NODE=1 exec '/Apps/Visual Studio Code.app/Contents/MacOS/O'\''Brien Code' "$@""#
);
assert!(!body.contains("export"), "the variable is never exported");
let sha = hex::encode(Sha256::digest(format!("{}\n", lines[2]).as_bytes()));
assert_eq!(lines[1], format!("# openlatch-cline-runtime {sha}"));
let hub = wrapper_body(Path::new("/A/code-sidecar"), "BUN_BE_BUN");
assert!(hub.contains("\nBUN_BE_BUN=1 exec '/A/code-sidecar' \"$@\"\n"));
}
#[test]
fn launcher_file_name_derives_from_launcher_bin() {
assert_eq!(launcher_file_name(false), LAUNCHER_BIN);
assert_eq!(launcher_file_name(true), format!("{LAUNCHER_BIN}.exe"));
}
#[test]
fn probe_record_keeps_its_on_disk_shape() {
let existing = r#"{"key":"k1","ok":false,"stage":"spawn","error":"boom","at_ms":42}"#;
let record: ProbeRecord = serde_json::from_str(existing).expect("parse");
assert_eq!(
record,
ProbeRecord {
key: "k1".to_string(),
result: ProbeResult::failed("spawn", "boom"),
at_ms: 42,
}
);
assert_eq!(
serde_json::to_value(&record).expect("serialize"),
serde_json::from_str::<serde_json::Value>(existing).expect("json")
);
let ok = r#"{"key":"k2","ok":true,"stage":null,"error":null,"at_ms":7}"#;
let record: ProbeRecord = serde_json::from_str(ok).expect("parse ok");
assert!(record.result.ok);
assert_eq!(
serde_json::to_value(&record).expect("serialize"),
serde_json::from_str::<serde_json::Value>(ok).expect("json")
);
}
#[test]
fn target_body_names_one_runtime_and_one_variable() {
assert_eq!(
target_body(Path::new("C:/VS Code/Code.exe"), "ELECTRON_RUN_AS_NODE"),
"runtime=C:/VS Code/Code.exe\nenv=ELECTRON_RUN_AS_NODE=1\n"
);
}
#[test]
fn stage_launcher_refreshes_and_renames_aside() {
let dir = tempfile::tempdir().expect("tempdir");
let source = dir.path().join("src-launcher");
let target = dir.path().join("bin").join("cline-js-runtime-vscode.exe");
std::fs::create_dir_all(target.parent().expect("parent")).expect("mkdir");
assert_eq!(
stage_launcher(&source, &target),
Err(WrapperError::LauncherMissing)
);
std::fs::write(&source, b"v1").expect("write");
stage_launcher(&source, &target).expect("place");
assert_eq!(std::fs::read(&target).expect("read"), b"v1");
stage_launcher(&source, &target).expect("already current");
std::fs::write(&source, b"v2").expect("write");
stage_launcher(&source, &target).expect("refresh");
assert_eq!(std::fs::read(&target).expect("read"), b"v2");
stage_launcher(&source, &target).expect("sweep");
let leftovers: Vec<_> = std::fs::read_dir(target.parent().expect("parent"))
.expect("read_dir")
.flatten()
.filter(|e| e.file_name().to_string_lossy().contains(".old-"))
.collect();
assert!(leftovers.is_empty(), "stale copies are swept");
}
#[test]
fn stage_launcher_restores_the_target_when_placement_fails() {
let dir = tempfile::tempdir().expect("tempdir");
let source = dir.path().join("src-launcher");
let target = dir.path().join("cline-js-runtime-vscode.exe");
std::fs::write(&source, b"v1").expect("write");
stage_launcher(&source, &target).expect("place");
let tmp = old_name(&target, u32::MAX).with_extension("openlatch-tmp");
std::fs::create_dir(&tmp).expect("squat");
std::fs::write(&source, b"v2").expect("write");
assert!(stage_launcher(&source, &target).is_err());
assert_eq!(
std::fs::read(&target).expect("target is still there"),
b"v1"
);
}
#[cfg(unix)]
#[test]
fn cline_runtime_never_writes_outside_ol_dir() {
use crate::hooks::cline::{cline_isolated, ASSETS_DIR_ENV, DATA_DIR_ENV, STORE_DIR_ENV};
use crate::hooks::cline_app::{write_fake_bundle, APP_DIR_ENV, BUNDLE_ID};
use crate::hooks::cline_hosts::VSCODE_APP_ENV;
use std::os::unix::fs::PermissionsExt;
let a = tempfile::tempdir().expect("tempdir A");
let b = tempfile::tempdir().expect("tempdir B");
let apps = a.path().join("apps");
let bundle = write_fake_bundle(&apps, BUNDLE_ID, "0.0.39");
let sidecar = bundle.join("Contents").join("MacOS").join("code-sidecar");
std::fs::write(&sidecar, "#!/bin/sh\nexit 1\n").expect("sidecar");
std::fs::set_permissions(&sidecar, std::fs::Permissions::from_mode(0o755)).expect("chmod");
let code = a.path().join("vscode").join("Code");
std::fs::create_dir_all(code.parent().expect("parent")).expect("mkdir");
std::fs::write(&code, "#!/bin/sh\nexit 1\n").expect("code");
std::fs::set_permissions(&code, std::fs::Permissions::from_mode(0o755)).expect("chmod");
let home = a.path().join("home");
let ext = home
.join(".vscode")
.join("extensions")
.join("saoudrizwan.claude-dev-4.1.22");
std::fs::create_dir_all(ext.join("dist")).expect("mkdir");
std::fs::write(
ext.join("dist").join("extension.js"),
r#"var p={name:"@cline/core",version:"0.0.88"};"#,
)
.expect("extension.js");
let _seam = cline_isolated([
(STORE_DIR_ENV, Some(a.path().join("store").into_os_string())),
(DATA_DIR_ENV, Some(a.path().join("data").into_os_string())),
(
ASSETS_DIR_ENV,
Some(a.path().join("assets").into_os_string()),
),
(APP_DIR_ENV, Some(apps.clone().into_os_string())),
(VSCODE_APP_ENV, Some(code.clone().into_os_string())),
("HOME", Some(home.clone().into_os_string())),
]);
let snapshot = |root: &Path| {
let mut out = BTreeMap::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).expect("read_dir").flatten() {
let meta = std::fs::symlink_metadata(entry.path()).expect("meta");
if meta.is_dir() {
stack.push(entry.path());
}
out.insert(entry.path(), (meta.len(), meta.modified().ok()));
}
}
out
};
let before = snapshot(a.path());
cline_bootstrap::materialise(b.path()).expect("materialise");
let hosts = crate::hooks::cline_hosts::detect(b.path());
assert_eq!(
hosts.iter().map(|h| h.class).collect::<Vec<_>>(),
vec![HostClass::Hub, HostClass::VsCode]
);
for host in &hosts {
let wrapper = write_wrapper(b.path(), host).expect("wrapper");
assert!(wrapper.starts_with(b.path()));
let result = probe(b.path(), host);
assert!(!result.ok, "a runtime that exits 1 cannot pass the probe");
}
assert_eq!(before, snapshot(a.path()), "nothing under A changed");
for path in snapshot(b.path()).keys() {
assert!(path.starts_with(b.path()));
}
let record: serde_json::Value = serde_json::from_slice(
&std::fs::read(b.path().join("state").join(PROBE_CACHE_FILE)).expect("probe cache"),
)
.expect("json");
assert_eq!(record["hub"]["ok"], false);
assert_eq!(record["vscode"]["ok"], false);
}
}