mirror of
https://github.com/lahfir/agent-desktop.git
synced 2026-07-29 02:12:52 +00:00
feat: install the windows private-file seam at both unskippable consumers
This commit is contained in:
parent
c95f15fa2e
commit
4e2ccc9bdd
7 changed files with 300 additions and 35 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
48
crates/ffi/tests/c_abi_session_liveness.rs
Normal file
48
crates/ffi/tests/c_abi_session_liveness.rs
Normal 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));
|
||||
});
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
95
crates/ffi/tests/c_abi_windows_private_file_install.rs
Normal file
95
crates/ffi/tests/c_abi_windows_private_file_install.rs
Normal 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:?}"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
144
src/tests/windows_private_file_install.rs
Normal file
144
src/tests/windows_private_file_install.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue