feat: install the windows private-file seam at both unskippable consumers

This commit is contained in:
Lahfir 2026-07-27 03:51:02 -06:00
parent c95f15fa2e
commit 4e2ccc9bdd
7 changed files with 300 additions and 35 deletions

View file

@ -115,6 +115,9 @@ fn build_adapter() -> Result<Box<dyn PlatformAdapter>, AdapterError> {
#[cfg(target_os = "windows")]
{
agent_desktop_windows::ensure_hosted_library_mta_and_dpi()?;
let _ = agent_desktop_core::install_private_file_ops(Box::new(
agent_desktop_windows::WindowsPrivateFile,
));
Ok(Box::new(agent_desktop_windows::WindowsAdapter::new()))
}
@ -334,9 +337,7 @@ mod tests {
struct UnknownPermissionAdapter;
impl ObservationOps for UnknownPermissionAdapter {}
impl ActionOps for UnknownPermissionAdapter {}
impl InputOps for UnknownPermissionAdapter {}
impl SystemOps for UnknownPermissionAdapter {

View file

@ -0,0 +1,48 @@
mod common;
use agent_desktop_core::session::{
GcOptions, SessionTraceMode, StartSessionOptions, gc, start_session, write_manifest,
};
use common::{ad_adapter_create_with_session, ad_adapter_destroy, with_isolated_home};
use std::ffi::CString;
use std::time::Duration;
/// Runs alone in its own process because private-file install state is
/// process-global: on Windows the first adapter construction installs
/// `WindowsPrivateFile`, whose atomic writes hold a process-lifetime temp
/// lease inside the written file's parent. If another adapter-creating test
/// ran first in this process, the session writes below would plant that
/// lease inside the session directory and the same-process gc removal would
/// fail by design. In the product, session writes and `session gc` never
/// share a process, so the isolation here models the real topology.
#[test]
fn session_scoped_adapter_holds_liveness_until_destroyed() {
with_isolated_home(|| {
let mut manifest = start_session(StartSessionOptions {
name: None,
trace: SessionTraceMode::Off,
..Default::default()
})
.unwrap();
manifest.created_at = 0;
write_manifest(&manifest).unwrap();
let session = CString::new(manifest.id.as_str()).unwrap();
let adapter = unsafe { ad_adapter_create_with_session(session.as_ptr()) };
assert!(!adapter.is_null());
let retained = gc(GcOptions {
ended_only: false,
older_than: Some(Duration::ZERO),
})
.unwrap();
assert!(!retained.removed.contains(&manifest.id));
unsafe { ad_adapter_destroy(adapter) };
let removed = gc(GcOptions {
ended_only: false,
older_than: Some(Duration::ZERO),
})
.unwrap();
assert!(removed.removed.contains(&manifest.id));
});
}

View file

@ -1,7 +1,7 @@
mod common;
use agent_desktop_core::session::{
GcOptions, SessionTraceMode, StartSessionOptions, gc, start_session, trace_dir, write_manifest,
SessionTraceMode, StartSessionOptions, start_session, trace_dir,
};
use common::{
AdResult, ad_adapter_create_with_session, ad_adapter_destroy, ad_check_permissions,
@ -10,7 +10,6 @@ use common::{
use std::ffi::CString;
use std::fs;
use std::sync::Mutex;
use std::time::Duration;
static HOME_LOCK: Mutex<()> = Mutex::new(());
@ -129,34 +128,3 @@ fn manifestless_session_does_not_create_trace_files() {
}
assert!(!trace_dir(session_id).unwrap().exists());
}
#[test]
fn session_scoped_adapter_holds_liveness_until_destroyed() {
let _home = TestHome::new();
let mut manifest = start_session(StartSessionOptions {
name: None,
trace: SessionTraceMode::Off,
..Default::default()
})
.unwrap();
manifest.created_at = 0;
write_manifest(&manifest).unwrap();
let session = CString::new(manifest.id.as_str()).unwrap();
let adapter = unsafe { ad_adapter_create_with_session(session.as_ptr()) };
assert!(!adapter.is_null());
let retained = gc(GcOptions {
ended_only: false,
older_than: Some(Duration::ZERO),
})
.unwrap();
assert!(!retained.removed.contains(&manifest.id));
unsafe { ad_adapter_destroy(adapter) };
let removed = gc(GcOptions {
ended_only: false,
older_than: Some(Duration::ZERO),
})
.unwrap();
assert!(removed.removed.contains(&manifest.id));
}

View file

@ -0,0 +1,95 @@
#![cfg(target_os = "windows")]
mod common;
use agent_desktop_core::session::{StartSessionOptions, start_session};
use agent_desktop_core::{PrivateFileOps, install_private_file_ops};
use common::{ad_adapter_create, ad_adapter_destroy, with_isolated_home};
use std::path::{Path, PathBuf};
use std::process::Command;
struct ProbeOps;
impl PrivateFileOps for ProbeOps {}
fn plant_junction(link: &Path, target: &Path) {
let output = Command::new("cmd")
.args(["/C", "mklink", "/J"])
.arg(link)
.arg(target)
.output()
.expect("cmd /c mklink starts");
assert!(
output.status.success(),
"mklink /J must succeed without privilege: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn regular_files_under(root: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
let mut pending = vec![root.to_path_buf()];
while let Some(directory) = pending.pop() {
let Ok(entries) = std::fs::read_dir(&directory) else {
continue;
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
pending.push(entry.path());
} else {
files.push(entry.path());
}
}
}
files
}
/// Core's five private-file primitives are `pub(crate)`, so the behavioral arm
/// reaches them through the public `session::start_session` surface instead:
/// the first-install rejection proves adapter construction already installed
/// an implementation (the windows arm installs only `WindowsPrivateFile`), and
/// the junction refusal ties that install to hardened behavior the portable
/// default measurably lacks. The spawned-binary junction proof in the CLI
/// crate covers the other consumer.
#[test]
fn adapter_create_without_ad_init_installs_the_windows_private_file_ops() {
unsafe {
let adapter = ad_adapter_create();
assert!(
!adapter.is_null(),
"ad_adapter_create must succeed without any prior ad_init call"
);
ad_adapter_destroy(adapter);
}
let Err(rejected) = install_private_file_ops(Box::new(ProbeOps)) else {
panic!(
"a fresh install must be rejected because adapter construction \
already installed the Windows private-file implementation"
);
};
drop(rejected);
with_isolated_home(|| {
let home = PathBuf::from(std::env::var_os("HOME").expect("isolated HOME is set"));
let target = home.join("junction-target");
std::fs::create_dir_all(&target).expect("create junction target");
plant_junction(&home.join(".agent-desktop"), &target);
let started = start_session(StartSessionOptions::default());
assert!(
started.is_err(),
"a manifest write through a junction component must be refused \
by the installed WindowsPrivateFile"
);
let leaked = regular_files_under(&target);
assert!(
leaked.is_empty(),
"no session artifact may land under the junction target: {leaked:?}"
);
});
}

View file

@ -39,5 +39,9 @@ path = "tests/conformance.rs"
name = "cli_process"
path = "tests/cli_process.rs"
[[test]]
name = "windows_private_file_install"
path = "tests/windows_private_file_install.rs"
[lints]
workspace = true

View file

@ -58,6 +58,11 @@ fn run() -> ExitCode {
return finish("unknown", Err(pre_dispatch_error(bootstrap_error.into())));
}
#[cfg(target_os = "windows")]
let _ = agent_desktop_core::install_private_file_ops(Box::new(
agent_desktop_windows::WindowsPrivateFile,
));
let mut cli = match Cli::try_parse() {
Ok(c) => c,
Err(e) => {

View file

@ -0,0 +1,144 @@
#![cfg(target_os = "windows")]
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
static SCRATCH_ID: AtomicU64 = AtomicU64::new(1);
struct Scratch {
root: PathBuf,
}
impl Scratch {
fn create(label: &str) -> Self {
let id = SCRATCH_ID.fetch_add(1, Ordering::Relaxed);
let root = std::env::temp_dir().join(format!(
"agent-desktop-install-{label}-{}-{id}",
std::process::id()
));
std::fs::create_dir_all(&root).expect("create scratch root");
Self { root }
}
fn dir(&self, name: &str) -> PathBuf {
let path = self.root.join(name);
std::fs::create_dir_all(&path).expect("create scratch subdirectory");
path
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
fn plant_junction(link: &Path, target: &Path) {
let output = Command::new("cmd")
.args(["/C", "mklink", "/J"])
.arg(link)
.arg(target)
.output()
.expect("cmd /c mklink starts");
assert!(
output.status.success(),
"mklink /J must succeed without privilege: {}",
String::from_utf8_lossy(&output.stderr)
);
let attributes = {
use std::os::windows::fs::MetadataExt;
std::fs::symlink_metadata(link)
.expect("junction link exists")
.file_attributes()
};
assert!(
attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0,
"planted link must carry FILE_ATTRIBUTE_REPARSE_POINT"
);
}
fn run_session_start(home: &Path) -> Output {
Command::new(env!("CARGO_BIN_EXE_agent-desktop"))
.args(["session", "start"])
.env("HOME", home)
.env("USERPROFILE", home)
.env_remove("AGENT_DESKTOP_SESSION")
.output()
.expect("binary starts")
}
fn parse_envelope(output: &Output) -> serde_json::Value {
serde_json::from_slice(&output.stdout).expect("stdout is one JSON envelope")
}
fn regular_files_under(root: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
let mut pending = vec![root.to_path_buf()];
while let Some(directory) = pending.pop() {
let Ok(entries) = std::fs::read_dir(&directory) else {
continue;
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
pending.push(entry.path());
} else {
files.push(entry.path());
}
}
}
files
}
#[test]
fn session_start_through_a_junction_home_is_refused_by_the_installed_windows_ops() {
let scratch = Scratch::create("junction");
let home = scratch.dir("junction-home");
let target = scratch.dir("junction-target");
plant_junction(&home.join(".agent-desktop"), &target);
let output = run_session_start(&home);
let envelope = parse_envelope(&output);
assert_eq!(
output.status.code(),
Some(1),
"session start must fail structurally when ~/.agent-desktop is a junction; \
success means the portable default wrote through the junction"
);
assert_eq!(envelope["ok"], false);
let leaked = regular_files_under(&target);
assert!(
leaked.is_empty(),
"no session artifact may land under the junction target: {leaked:?}"
);
}
#[test]
fn session_start_in_a_real_home_succeeds_as_the_junction_control() {
let scratch = Scratch::create("control");
let home = scratch.dir("real-home");
let output = run_session_start(&home);
let envelope = parse_envelope(&output);
assert_eq!(output.status.code(), Some(0));
assert_eq!(envelope["ok"], true);
let session_id = envelope["data"]["session_id"]
.as_str()
.expect("session start reports its session id");
let manifest = home
.join(".agent-desktop")
.join("sessions")
.join(session_id)
.join("session.json");
assert!(
manifest.is_file(),
"the control session manifest must exist under the real home"
);
}