This commit is contained in:
Lahfir 2026-07-27 11:28:50 +00:00 committed by GitHub
commit 2f766ac40c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 4622 additions and 274 deletions

View file

@ -223,7 +223,7 @@ jobs:
test-windows:
name: Test (Windows)
runs-on: windows-latest
timeout-minutes: 20
timeout-minutes: 45
permissions:
contents: read
steps:
@ -232,33 +232,151 @@ jobs:
- name: Install Rust toolchain
run: rustup show
# Core resolves its data dir via HOME then USERPROFILE (crates/core/src/refs.rs
# fn home_dir), and env assigned inside one run: block does not survive into
# the next step, so the isolation is hoisted here and exported through
# GITHUB_ENV for every later step. CARGO_HOME and RUSTUP_HOME must be resolved
# from the real USERPROFILE first (rustup/cargo default them to
# %USERPROFILE%\.rustup and %USERPROFILE%\.cargo) so repointing USERPROFILE at
# the empty test home doesn't strand the installed toolchain.
# ORIGINAL_USERPROFILE feeds the trailing profile-isolation guard.
- name: Isolate HOME for the whole lane
shell: pwsh
run: |
$cargoHome = if ($env:CARGO_HOME) { $env:CARGO_HOME } else { Join-Path $env:USERPROFILE '.cargo' }
$rustupHome = if ($env:RUSTUP_HOME) { $env:RUSTUP_HOME } else { Join-Path $env:USERPROFILE '.rustup' }
$testHome = Join-Path $env:RUNNER_TEMP ("agent-desktop-test-home-" + [guid]::NewGuid())
New-Item -ItemType Directory -Path $testHome | Out-Null
Add-Content -Path $env:GITHUB_ENV -Value "ORIGINAL_USERPROFILE=$env:USERPROFILE"
Add-Content -Path $env:GITHUB_ENV -Value "CARGO_HOME=$cargoHome"
Add-Content -Path $env:GITHUB_ENV -Value "RUSTUP_HOME=$rustupHome"
Add-Content -Path $env:GITHUB_ENV -Value "HOME=$testHome"
Add-Content -Path $env:GITHUB_ENV -Value "USERPROFILE=$testHome"
# HOME and USERPROFILE now point into RUNNER_TEMP for every remaining step
# (including the post-job cache save), so ~ would miss the real registry; the
# cache paths must go through the hoisted CARGO_HOME instead.
- name: Cache cargo registry
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
${{ env.CARGO_HOME }}/registry/index/
${{ env.CARGO_HOME }}/registry/cache/
${{ env.CARGO_HOME }}/git/db/
key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src/Cargo.toml') }}
restore-keys: ${{ runner.os }}-cargo-
# Core resolves its data dir via HOME then USERPROFILE (crates/core/src/refs.rs
# fn home_dir), so pointing HOME at a fresh runner-temp directory is enough to
# keep tests off the real profile — the bash isolation script the macOS/Linux
# lanes use doesn't apply here since this step runs under pwsh. CARGO_HOME and
# RUSTUP_HOME must be resolved from the real USERPROFILE first (rustup/cargo
# default them to %USERPROFILE%\.rustup and %USERPROFILE%\.cargo) so repointing
# USERPROFILE at the empty test home doesn't strand the installed toolchain.
- name: Core and Windows unit tests (isolated HOME)
- name: Cache build artifacts
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: target/
key: ${{ runner.os }}-build-ci-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src/Cargo.toml') }}-${{ hashFiles('crates/**/*.rs', 'src/**/*.rs', 'tests/**/*.rs') }}
restore-keys: |
${{ runner.os }}-build-ci-${{ hashFiles('Cargo.lock', 'Cargo.toml', 'crates/**/Cargo.toml', 'src/Cargo.toml') }}-
${{ runner.os }}-build-ci-
# Widened relative to the macOS-lane check: dev edges are included (a Win32
# binding crate hiding in dev-dependencies otherwise passes clean) and the
# resolution is additionally pinned to the MSVC target.
- name: Check dependency isolation
shell: pwsh
run: |
$env:CARGO_HOME = if ($env:CARGO_HOME) { $env:CARGO_HOME } else { Join-Path $env:USERPROFILE '.cargo' }
$env:RUSTUP_HOME = if ($env:RUSTUP_HOME) { $env:RUSTUP_HOME } else { Join-Path $env:USERPROFILE '.rustup' }
$testHome = Join-Path $env:RUNNER_TEMP ("agent-desktop-test-home-" + [guid]::NewGuid())
New-Item -ItemType Directory -Path $testHome | Out-Null
$env:HOME = $testHome
$env:USERPROFILE = $testHome
cargo test --locked -p agent-desktop-core -p agent-desktop-windows --lib
$pattern = 'agent-desktop-(macos|windows|linux)|\bwindows-sys\b|\bwindows\b|\bwinapi\b'
foreach ($target in @('', 'x86_64-pc-windows-msvc')) {
$arguments = @('tree', '--locked', '-p', 'agent-desktop-core', '--edges', 'normal,build,dev')
if ($target) { $arguments += @('--target', $target) }
$tree = cargo @arguments
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$violations = @($tree | Select-String -Pattern $pattern)
if ($violations.Count -gt 0) {
$violations | ForEach-Object { Write-Host $_.Line }
Write-Host "FAIL: core crate pulls platform or Win32 binding crates (target: '$target')"
exit 1
}
}
Write-Host 'OK: core crate has no platform dependencies on normal, build, or dev edges'
# The dependency check cannot see hand-written externs, so reject the raw
# Win32 surface at source level. The allowlist is the pair of portable no-op
# directory-sync shims in private_file.rs; the count is pinned so a third
# windows conditional cannot ride in on the allowlisted file either.
- name: Check core source for Win32 leakage
shell: pwsh
run: |
$sources = Get-ChildItem -Path crates/core/src -Recurse -Filter *.rs
$forbidden = @($sources | Select-String -Pattern 'extern "system|std::os::windows|\bwinapi\b|\bwindows_sys\b')
if ($forbidden.Count -gt 0) {
$forbidden | ForEach-Object { Write-Host "$($_.Path):$($_.LineNumber): $($_.Line.Trim())" }
Write-Host 'FAIL: core source declares a raw Win32 surface'
exit 1
}
$allowed = (Resolve-Path 'crates/core/src/private_file.rs').Path
$cfgMatches = @($sources | Select-String -Pattern 'cfg\(windows\)|cfg\(target_os = "windows"\)' -AllMatches)
$outside = @($cfgMatches | Where-Object { $_.Path -ne $allowed })
if ($outside.Count -gt 0) {
$outside | ForEach-Object { Write-Host "$($_.Path):$($_.LineNumber): $($_.Line.Trim())" }
Write-Host 'FAIL: windows cfg outside the private_file.rs allowlist'
exit 1
}
$shimCount = [int]($cfgMatches | ForEach-Object { $_.Matches.Count } | Measure-Object -Sum).Sum
if ($shimCount -ne 2) {
Write-Host "FAIL: expected exactly 2 windows cfg shims in private_file.rs, found $shimCount"
exit 1
}
Write-Host 'OK: core source keeps windows conditionals to the 2 allowlisted shims'
- name: Clippy
run: cargo clippy --locked -p agent-desktop-core -p agent-desktop-windows -p agent-desktop -p agent-desktop-ffi --all-targets -- -D warnings
- name: Core and Windows unit tests
run: cargo test --locked -p agent-desktop-core -p agent-desktop-windows --lib
- name: Binary command tests
run: cargo test --locked -p agent-desktop
# The FFI crate ships integration harnesses under crates/ffi/tests/ that a
# --lib run skips, and the Windows-gated reachability coverage landing there
# must execute on this runner, so wire them in explicitly.
- name: FFI integration tests
run: cargo test --locked -p agent-desktop-ffi --tests
- name: Build stripped release binary
run: cargo build --locked --release -p agent-desktop
# The macOS lane's stat -f%z is BSD-only and fails under Git Bash, so the
# size gate is native pwsh. There is no helper binary on Windows — the cap
# applies to the one shipped executable.
- name: Check shipped binary size
shell: pwsh
run: |
$binary = Get-Item target/release/agent-desktop.exe
$limit = 15MB
Write-Host "Binary size: $($binary.Length) bytes"
if ($binary.Length -gt $limit) {
Write-Host "FAIL: shipped executable exceeds 15MB limit (binary=$($binary.Length) bytes)"
exit 1
}
Write-Host 'OK: shipped executable is within the 15MB per-file limit'
# The isolation scenario this lane must prove: a test that writes a private
# artifact observes it under RUNNER_TEMP, never the runner profile.
- name: Guard profile isolation
shell: pwsh
run: |
if (-not $env:HOME -or -not $env:HOME.StartsWith($env:RUNNER_TEMP, [System.StringComparison]::OrdinalIgnoreCase)) {
Write-Host "FAIL: HOME is not under RUNNER_TEMP: $env:HOME"
exit 1
}
if (-not $env:USERPROFILE -or -not $env:USERPROFILE.StartsWith($env:RUNNER_TEMP, [System.StringComparison]::OrdinalIgnoreCase)) {
Write-Host "FAIL: USERPROFILE is not under RUNNER_TEMP: $env:USERPROFILE"
exit 1
}
$leak = Join-Path $env:ORIGINAL_USERPROFILE '.agent-desktop'
if (Test-Path $leak) {
Write-Host "FAIL: a test escaped the isolated HOME and wrote $leak"
exit 1
}
Write-Host 'OK: private artifacts stayed under RUNNER_TEMP'
ffi-python-smoke:
name: FFI Python Smoke

1
Cargo.lock generated
View file

@ -89,6 +89,7 @@ version = "0.6.0"
dependencies = [
"agent-desktop-core",
"thiserror",
"windows-sys",
]
[[package]]

View file

@ -68,6 +68,7 @@ pub mod permission_report;
pub mod permission_state;
mod point;
mod private_file;
mod private_file_ops;
mod private_file_parent;
mod process_id;
mod process_identity;
@ -224,6 +225,9 @@ pub use output::{ErrorPayload, Response};
pub use permission_report::PermissionReport;
pub use permission_state::PermissionState;
pub use point::Point;
pub use private_file_ops::{
PrivateFileOps, bounded_read, install_private_file_ops, temporary_file_name,
};
pub use process_id::ProcessId;
pub use process_identity::ProcessIdentity;
pub use recovery_hint::RecoveryHint;

View file

@ -1,17 +1,17 @@
use std::fs::File;
use std::fs::OpenOptions;
use std::hash::{BuildHasher, RandomState};
use std::io::{Read, Write};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
pub(crate) fn open_private_lock(path: &Path, create: bool) -> std::io::Result<File> {
crate::private_file_ops::with_active_ops(|ops| ops.open_private_lock(path, create))
}
pub(crate) fn open_private_lock_portable(path: &Path, create: bool) -> std::io::Result<File> {
let parent = path
.parent()
.ok_or_else(|| invalid_input("private file path has no parent"))?;
crate::private_file_parent::ensure_private(parent)?;
crate::private_file_parent::ensure_private_portable(parent)?;
let mut options = OpenOptions::new();
options.read(true).write(true).create(create);
configure_unix(&mut options, 0o600);
@ -20,10 +20,14 @@ pub(crate) fn open_private_lock(path: &Path, create: bool) -> std::io::Result<Fi
Ok(file)
}
pub(crate) fn open_private_append(path: &Path) -> std::io::Result<File> {
crate::private_file_ops::with_active_ops(|ops| ops.open_private_append(path))
}
/// Read access is requested alongside append because callers lock the returned
/// handle, and Windows `LockFileEx` fails with `ERROR_ACCESS_DENIED` on a handle
/// opened for append only.
pub(crate) fn open_private_append(path: &Path) -> std::io::Result<File> {
pub(crate) fn open_private_append_portable(path: &Path) -> std::io::Result<File> {
let mut options = OpenOptions::new();
options.read(true).create(true).append(true);
configure_unix(&mut options, 0o600);
@ -33,8 +37,15 @@ pub(crate) fn open_private_append(path: &Path) -> std::io::Result<File> {
}
pub(crate) fn read_private_bounded(path: &Path, max_bytes: u64) -> std::io::Result<Vec<u8>> {
crate::private_file_ops::with_active_ops(|ops| ops.read_private_bounded(path, max_bytes))
}
pub(crate) fn read_private_bounded_portable(
path: &Path,
max_bytes: u64,
) -> std::io::Result<Vec<u8>> {
let file = open_private_read(path)?;
read_bounded(file, max_bytes)
crate::private_file_ops::bounded_read(file, max_bytes)
}
pub(crate) fn read_regular_bounded(path: &Path, max_bytes: u64) -> std::io::Result<Vec<u8>> {
@ -46,14 +57,18 @@ pub(crate) fn read_regular_bounded(path: &Path, max_bytes: u64) -> std::io::Resu
};
validate_regular(&file)?;
validate_local_filesystem(&file)?;
read_bounded(file, max_bytes)
crate::private_file_ops::bounded_read(file, max_bytes)
}
pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
crate::private_file_ops::with_active_ops(|ops| ops.write_atomic(path, bytes))
}
pub(crate) fn write_atomic_portable(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
write_atomic_with(
path,
bytes,
crate::private_file_parent::ensure_private,
crate::private_file_parent::ensure_private_portable,
sync_directory,
validate_private_destination,
)
@ -82,16 +97,15 @@ fn write_atomic_with(
ensure_parent(parent)?;
validate_destination(path)?;
let (temporary, mut file) = create_temporary(path)?;
let result = (|| {
file.write_all(bytes)?;
file.sync_all()?;
let written = file.write_all(bytes).and_then(|()| file.sync_all());
drop(file);
let result = written.and_then(|()| {
#[cfg(test)]
crash_before_rename_if_requested(path);
replace_atomic(&temporary, path)?;
validate_private_regular(&open_private_read(path)?)?;
sync_parent(parent)
})();
drop(file);
});
if result.is_err() {
let _ = std::fs::remove_file(&temporary);
}
@ -159,33 +173,15 @@ fn open_private_read(path: &Path) -> std::io::Result<File> {
Ok(file)
}
fn read_bounded(file: File, max_bytes: u64) -> std::io::Result<Vec<u8>> {
let metadata = file.metadata()?;
if metadata.len() > max_bytes {
return Err(invalid_input("file exceeds its read limit"));
}
let capacity = usize::try_from(metadata.len().min(max_bytes)).unwrap_or(usize::MAX);
let mut bytes = Vec::with_capacity(capacity);
file.take(max_bytes.saturating_add(1))
.read_to_end(&mut bytes)?;
if bytes.len() as u64 > max_bytes {
return Err(invalid_input("file grew beyond its read limit"));
}
Ok(bytes)
}
fn create_temporary(path: &Path) -> std::io::Result<(PathBuf, File)> {
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| invalid_input("private file path has an invalid filename"))?;
for _ in 0..32 {
let nonce = RandomState::new().hash_one((
std::process::id(),
TEMP_COUNTER.fetch_add(1, Ordering::Relaxed),
std::time::SystemTime::now(),
let temporary = path.with_file_name(crate::private_file_ops::temporary_file_name(
std::ffi::OsStr::new(file_name),
));
let temporary = path.with_file_name(format!(".{file_name}.{nonce:016x}.tmp"));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
configure_unix(&mut options, 0o600);

View file

@ -0,0 +1,136 @@
use std::ffi::{OsStr, OsString};
use std::fs::File;
use std::hash::{BuildHasher, RandomState};
use std::io::Read;
use std::path::Path;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU64, Ordering};
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
/// Reads `file` fully on behalf of a `PrivateFileOps` implementation,
/// rejecting files larger than `max_bytes` before allocating and detecting
/// growth past the limit during the read.
pub fn bounded_read(file: File, max_bytes: u64) -> std::io::Result<Vec<u8>> {
let metadata = file.metadata()?;
if metadata.len() > max_bytes {
return Err(read_limit_error("file exceeds its read limit"));
}
let capacity = usize::try_from(metadata.len().min(max_bytes)).unwrap_or(usize::MAX);
let mut bytes = Vec::with_capacity(capacity);
file.take(max_bytes.saturating_add(1))
.read_to_end(&mut bytes)?;
if bytes.len() as u64 > max_bytes {
return Err(read_limit_error("file grew beyond its read limit"));
}
Ok(bytes)
}
fn read_limit_error(message: &'static str) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::InvalidData, message)
}
/// Produces one `.{name}.{nonce:016x}.tmp` candidate for a temporary that
/// will be promoted over a destination whose leaf name is `name`.
///
/// The hashed nonce keeps the name unpredictable to a same-privilege racer;
/// callers loop over fresh candidates when creation collides. Every
/// `PrivateFileOps` implementation names its temporaries through this one
/// scheme.
pub fn temporary_file_name(name: &OsStr) -> OsString {
let nonce = RandomState::new().hash_one((
std::process::id(),
TEMP_COUNTER.fetch_add(1, Ordering::Relaxed),
std::time::SystemTime::now(),
));
let mut temporary = OsString::from(".");
temporary.push(name);
temporary.push(format!(".{nonce:016x}.tmp"));
temporary
}
/// Platform seam for the five private-file primitives.
///
/// Every method defaults to the portable behavior used when no platform
/// implementation is installed, so an implementation overrides only the
/// operations its filesystem semantics require.
pub trait PrivateFileOps: Send + Sync {
/// Writes `bytes` to `path` as one atomic replacement, owning temporary
/// creation, handle lifetime, and replace ordering end to end.
fn write_atomic(&self, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
crate::private_file::write_atomic_portable(path, bytes)
}
/// Opens `path` for private appends; the returned handle must be lockable.
fn open_private_append(&self, path: &Path) -> std::io::Result<File> {
crate::private_file::open_private_append_portable(path)
}
/// Opens `path` read-write for locking, creating it when `create` is set.
fn open_private_lock(&self, path: &Path, create: bool) -> std::io::Result<File> {
crate::private_file::open_private_lock_portable(path, create)
}
/// Reads `path` fully, rejecting files larger than `max_bytes`.
fn read_private_bounded(&self, path: &Path, max_bytes: u64) -> std::io::Result<Vec<u8>> {
crate::private_file::read_private_bounded_portable(path, max_bytes)
}
/// Creates the directory chain at `path` and enforces that it is private.
fn ensure_private(&self, path: &Path) -> std::io::Result<()> {
crate::private_file_parent::ensure_private_portable(path)
}
}
struct PortablePrivateFileOps;
impl PrivateFileOps for PortablePrivateFileOps {}
static INSTALLED_OPS: OnceLock<Box<dyn PrivateFileOps>> = OnceLock::new();
/// Installs the process-wide private-file operations.
///
/// The first installation wins; a later call leaves the installed operations
/// untouched and returns the rejected implementation.
pub fn install_private_file_ops(
ops: Box<dyn PrivateFileOps>,
) -> Result<(), Box<dyn PrivateFileOps>> {
INSTALLED_OPS.set(ops)
}
pub(crate) fn with_active_ops<R>(operate: impl FnOnce(&dyn PrivateFileOps) -> R) -> R {
#[cfg(test)]
if let Some(ops) = TEST_OPS_OVERRIDE.with(|cell| cell.borrow().clone()) {
return operate(ops.as_ref());
}
match INSTALLED_OPS.get() {
Some(ops) => operate(ops.as_ref()),
None => operate(&PortablePrivateFileOps),
}
}
#[cfg(test)]
thread_local! {
static TEST_OPS_OVERRIDE: std::cell::RefCell<Option<std::rc::Rc<dyn PrivateFileOps>>> =
const { std::cell::RefCell::new(None) };
}
#[cfg(test)]
pub(crate) fn with_test_ops_override<R>(
ops: std::rc::Rc<dyn PrivateFileOps>,
run: impl FnOnce() -> R,
) -> R {
struct ClearOverrideOnDrop;
impl Drop for ClearOverrideOnDrop {
fn drop(&mut self) {
TEST_OPS_OVERRIDE.with(|cell| cell.borrow_mut().take());
}
}
TEST_OPS_OVERRIDE.with(|cell| *cell.borrow_mut() = Some(ops));
let _clear_override = ClearOverrideOnDrop;
run()
}
#[cfg(test)]
#[path = "private_file_ops_tests.rs"]
mod tests;

View file

@ -0,0 +1,190 @@
use super::*;
use std::io::ErrorKind;
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Mutex;
struct RecordingOps {
calls: Mutex<Vec<&'static str>>,
}
impl RecordingOps {
fn new() -> Rc<Self> {
Rc::new(Self {
calls: Mutex::new(Vec::new()),
})
}
fn record(&self, call: &'static str) {
self.calls.lock().unwrap().push(call);
}
fn calls(&self) -> Vec<&'static str> {
self.calls.lock().unwrap().clone()
}
}
impl PrivateFileOps for RecordingOps {
fn write_atomic(&self, _path: &Path, _bytes: &[u8]) -> std::io::Result<()> {
self.record("write_atomic");
Ok(())
}
fn open_private_append(&self, _path: &Path) -> std::io::Result<File> {
self.record("open_private_append");
Err(std::io::Error::new(ErrorKind::Unsupported, "fake append"))
}
fn open_private_lock(&self, _path: &Path, _create: bool) -> std::io::Result<File> {
self.record("open_private_lock");
Err(std::io::Error::new(ErrorKind::Unsupported, "fake lock"))
}
fn read_private_bounded(&self, _path: &Path, _max_bytes: u64) -> std::io::Result<Vec<u8>> {
self.record("read_private_bounded");
Ok(b"routed".to_vec())
}
fn ensure_private(&self, _path: &Path) -> std::io::Result<()> {
self.record("ensure_private");
Ok(())
}
}
fn untouched_path(name: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"agent-desktop-ops-{name}-{}-{}",
std::process::id(),
crate::refs::new_snapshot_id()
))
}
fn scratch_directory(name: &str) -> PathBuf {
let directory = untouched_path(name);
crate::private_file_parent::ensure_private(&directory).unwrap();
directory
}
#[test]
fn every_primitive_routes_through_the_overriding_ops() {
let missing = untouched_path("route-five");
let recorder = RecordingOps::new();
with_test_ops_override(recorder.clone(), || {
crate::private_file::write_atomic(&missing, b"bytes").unwrap();
let append_error = crate::private_file::open_private_append(&missing).unwrap_err();
assert_eq!(append_error.kind(), ErrorKind::Unsupported);
let lock_error = crate::private_file::open_private_lock(&missing, true).unwrap_err();
assert_eq!(lock_error.kind(), ErrorKind::Unsupported);
assert_eq!(
crate::private_file::read_private_bounded(&missing, 8).unwrap(),
b"routed"
);
crate::private_file_parent::ensure_private(&missing).unwrap();
});
assert_eq!(
recorder.calls(),
vec![
"write_atomic",
"open_private_append",
"open_private_lock",
"read_private_bounded",
"ensure_private",
]
);
assert!(!missing.exists());
}
#[test]
fn the_override_is_scoped_to_its_thread() {
let recorder = RecordingOps::new();
with_test_ops_override(recorder.clone(), || {
let directory = std::thread::spawn(|| {
let directory = scratch_directory("other-thread");
crate::private_file::write_atomic(&directory.join("data"), b"portable").unwrap();
directory
})
.join()
.unwrap();
assert_eq!(std::fs::read(directory.join("data")).unwrap(), b"portable");
assert_eq!(
crate::private_file::read_private_bounded(&directory.join("data"), 16).unwrap(),
b"routed"
);
std::fs::remove_dir_all(directory).unwrap();
});
assert_eq!(recorder.calls(), vec!["read_private_bounded"]);
}
#[test]
fn portable_behavior_resumes_after_the_scoped_override() {
let directory = scratch_directory("restore");
let path = directory.join("data");
let recorder = RecordingOps::new();
with_test_ops_override(recorder.clone(), || {
crate::private_file::write_atomic(&path, b"faked").unwrap();
});
assert!(!path.exists());
crate::private_file::write_atomic(&path, b"portable").unwrap();
assert_eq!(
crate::private_file::read_private_bounded(&path, 16).unwrap(),
b"portable"
);
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn a_panicking_override_scope_still_restores_portable_behavior() {
let directory = scratch_directory("panic-restore");
let path = directory.join("data");
let recorder = RecordingOps::new();
let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
with_test_ops_override(recorder, || panic!("scope failure"));
}));
assert!(panicked.is_err());
crate::private_file::write_atomic(&path, b"portable").unwrap();
assert_eq!(
crate::private_file::read_private_bounded(&path, 16).unwrap(),
b"portable"
);
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn installing_ops_a_second_time_is_rejected() {
assert!(install_private_file_ops(Box::new(PortablePrivateFileOps)).is_ok());
assert!(install_private_file_ops(Box::new(PortablePrivateFileOps)).is_err());
}
#[test]
fn temporary_file_name_has_the_hidden_hex_nonce_shape_and_varies_between_calls() {
let first = temporary_file_name(OsStr::new("refmap.json"));
let first = first
.to_str()
.expect("the temporary name must be valid UTF-8");
let nonce = first
.strip_prefix(".refmap.json.")
.and_then(|rest| rest.strip_suffix(".tmp"))
.expect("the name must lead with a dot and the destination name and end with .tmp");
assert_eq!(nonce.len(), 16, "the nonce must be 16 hex digits: {nonce}");
assert!(
nonce.chars().all(|digit| digit.is_ascii_hexdigit()),
"the nonce must be hexadecimal: {nonce}"
);
let second = temporary_file_name(OsStr::new("refmap.json"));
assert_ne!(
OsString::from(first),
second,
"two successive calls must produce different nonces"
);
}

View file

@ -1,6 +1,10 @@
use std::path::Path;
pub(super) fn ensure_private(path: &Path) -> std::io::Result<()> {
crate::private_file_ops::with_active_ops(|ops| ops.ensure_private(path))
}
pub(super) fn ensure_private_portable(path: &Path) -> std::io::Result<()> {
ensure_directory_path(path)?;
let metadata = std::fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {

View file

@ -1,9 +1,11 @@
#![cfg(unix)]
use super::*;
#[cfg(unix)]
use std::ffi::CString;
#[cfg(unix)]
use std::os::fd::AsRawFd;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
fn directory(label: &str) -> PathBuf {
@ -12,10 +14,12 @@ fn directory(label: &str) -> PathBuf {
crate::refs::new_snapshot_id()
));
std::fs::create_dir_all(&path).unwrap();
#[cfg(unix)]
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
path
}
#[cfg(unix)]
#[test]
fn private_open_sets_nonblocking_and_close_on_exec() {
let directory = directory("flags");
@ -29,6 +33,7 @@ fn private_open_sets_nonblocking_and_close_on_exec() {
std::fs::remove_dir_all(directory).unwrap();
}
#[cfg(unix)]
#[test]
fn private_open_rejects_symlink_fifo_device_and_hardlink() {
let directory = directory("special");
@ -70,6 +75,7 @@ fn private_read_enforces_the_bound_before_allocating() {
std::fs::remove_dir_all(directory).unwrap();
}
#[cfg(unix)]
#[test]
fn private_writes_and_locks_reject_group_accessible_parent() {
let directory = directory("hostile-parent");
@ -89,6 +95,7 @@ fn private_writes_and_locks_reject_group_accessible_parent() {
std::fs::remove_dir_all(directory).unwrap();
}
#[cfg(unix)]
#[test]
fn private_write_rejects_an_intermediate_directory_symlink() {
let directory = directory("intermediate-symlink");
@ -107,6 +114,7 @@ fn private_write_rejects_an_intermediate_directory_symlink() {
std::fs::remove_dir_all(directory).unwrap();
}
#[cfg(unix)]
#[test]
fn user_write_overwrites_an_existing_group_readable_file() {
let directory = directory("user-overwrite");
@ -125,6 +133,7 @@ fn user_write_overwrites_an_existing_group_readable_file() {
std::fs::remove_dir_all(directory).unwrap();
}
#[cfg(unix)]
#[test]
fn user_write_refuses_symlink_and_directory_destinations() {
let directory = directory("user-refuse");
@ -156,6 +165,7 @@ fn user_write_refuses_symlink_and_directory_destinations() {
std::fs::remove_dir_all(directory).unwrap();
}
#[cfg(unix)]
#[test]
fn private_write_still_rejects_a_group_readable_destination() {
let directory = directory("private-loose-destination");
@ -170,6 +180,7 @@ fn private_write_still_rejects_a_group_readable_destination() {
std::fs::remove_dir_all(directory).unwrap();
}
#[cfg(unix)]
#[test]
fn user_write_allows_the_system_temporary_directory() {
let path = Path::new("/tmp").join(format!(
@ -186,3 +197,62 @@ fn user_write_allows_the_system_temporary_directory() {
);
std::fs::remove_file(path).unwrap();
}
#[test]
fn atomic_write_lands_content_and_replaces_the_previous_file() {
let directory = directory("atomic-replace");
let path = directory.join("data");
write_atomic(&path, b"first").unwrap();
write_atomic(&path, b"second").unwrap();
assert_eq!(read_private_bounded(&path, 64).unwrap(), b"second");
assert_eq!(std::fs::read_dir(&directory).unwrap().count(), 1);
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn appended_writes_accumulate_across_reopens() {
let directory = directory("append-accumulate");
let path = directory.join("trace.jsonl");
open_private_append(&path)
.unwrap()
.write_all(b"one\n")
.unwrap();
open_private_append(&path)
.unwrap()
.write_all(b"two\n")
.unwrap();
assert_eq!(read_private_bounded(&path, 64).unwrap(), b"one\ntwo\n");
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn private_lock_creates_and_reopens_the_lock_file() {
let directory = directory("lock-create");
let path = directory.join("lock");
drop(open_private_lock(&path, true).unwrap());
assert!(path.is_file());
drop(open_private_lock(&path, false).unwrap());
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn ensure_private_creates_the_nested_parent_chain() {
let directory = directory("nested-parents");
let nested = directory.join("outer").join("inner");
crate::private_file_parent::ensure_private(&nested).unwrap();
assert!(nested.is_dir());
write_atomic(&nested.join("data"), b"nested").unwrap();
assert_eq!(
read_private_bounded(&nested.join("data"), 16).unwrap(),
b"nested"
);
std::fs::remove_dir_all(directory).unwrap();
}

View file

@ -236,7 +236,7 @@ fn create_session_tree(dir: &Path) -> Result<(), AppError> {
}
#[cfg(not(unix))]
{
std::fs::create_dir_all(dir.join("trace"))?;
crate::private_file_parent::ensure_private(&dir.join("trace"))?;
}
Ok(())
}

View file

@ -248,7 +248,7 @@ pub(crate) fn ensure_trace_dir(dir: &Path) -> Result<(), AppError> {
.create(dir)?;
}
#[cfg(not(unix))]
std::fs::create_dir_all(dir)?;
crate::private_file_parent::ensure_private(dir)?;
Ok(())
}

View file

@ -114,6 +114,7 @@ fn build_adapter() -> Result<Box<dyn PlatformAdapter>, AdapterError> {
#[cfg(target_os = "windows")]
{
agent_desktop_windows::bootstrap_hosted_library()?;
Ok(Box::new(agent_desktop_windows::WindowsAdapter::new()))
}
@ -333,9 +334,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,47 @@
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 for env-var hygiene: the isolated HOME
/// swap is process-wide, so a dedicated process keeps it from interleaving
/// with adapter state other suites establish. The historical gc hazard is
/// gone — on Windows the installed `WindowsPrivateFile` now scopes its temp
/// lease to each atomic write, so no process-lifetime directory handle
/// lingers inside the session directory and same-process gc removal (as
/// exercised below) succeeds against everything this process wrote.
#[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,55 +1,14 @@
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,
ad_free_string, ad_status,
ad_free_string, ad_status, with_isolated_home,
};
use std::ffi::CString;
use std::fs;
use std::sync::Mutex;
use std::time::Duration;
static HOME_LOCK: Mutex<()> = Mutex::new(());
struct TestHome {
_lock: std::sync::MutexGuard<'static, ()>,
dir: std::path::PathBuf,
previous: Option<std::ffi::OsString>,
}
impl TestHome {
fn new() -> Self {
let lock = HOME_LOCK.lock().unwrap();
let dir = std::env::temp_dir().join(format!(
"agent-desktop-ffi-session-trace-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&dir).unwrap();
let previous = std::env::var_os("HOME");
unsafe { std::env::set_var("HOME", &dir) };
Self {
_lock: lock,
dir,
previous,
}
}
}
impl Drop for TestHome {
fn drop(&mut self) {
match self.previous.as_ref() {
Some(previous) => unsafe { std::env::set_var("HOME", previous) },
None => unsafe { std::env::remove_var("HOME") },
}
let _ = fs::remove_dir_all(&self.dir);
}
}
fn trace_segments(session_id: &str) -> Vec<std::path::PathBuf> {
fs::read_dir(trace_dir(session_id).unwrap())
@ -79,84 +38,55 @@ unsafe fn call_status(session_id: &str) {
#[test]
fn traced_ffi_commands_reuse_one_process_segment_and_emit_ordered_boundaries() {
let _home = TestHome::new();
let manifest = start_session(StartSessionOptions {
name: None,
trace: SessionTraceMode::On,
..Default::default()
})
.unwrap();
with_isolated_home(|| {
let manifest = start_session(StartSessionOptions {
name: None,
trace: SessionTraceMode::On,
..Default::default()
})
.unwrap();
unsafe {
call_status(&manifest.id);
call_status(&manifest.id);
}
unsafe {
call_status(&manifest.id);
call_status(&manifest.id);
}
let segments = trace_segments(&manifest.id);
assert_eq!(segments.len(), 1);
let events: Vec<serde_json::Value> = fs::read_to_string(&segments[0])
.unwrap()
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.filter(|event: &serde_json::Value| event["command"].as_str() == Some("status"))
.collect();
let boundaries: Vec<_> = events
.iter()
.filter_map(|event| event["event"].as_str())
.filter(|event| matches!(*event, "command.start" | "command.end"))
.collect();
assert_eq!(
boundaries,
[
"command.start",
"command.end",
"command.start",
"command.end"
]
);
let segments = trace_segments(&manifest.id);
assert_eq!(segments.len(), 1);
let events: Vec<serde_json::Value> = fs::read_to_string(&segments[0])
.unwrap()
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.filter(|event: &serde_json::Value| event["command"].as_str() == Some("status"))
.collect();
let boundaries: Vec<_> = events
.iter()
.filter_map(|event| event["event"].as_str())
.filter(|event| matches!(*event, "command.start" | "command.end"))
.collect();
assert_eq!(
boundaries,
[
"command.start",
"command.end",
"command.start",
"command.end"
]
);
});
}
#[test]
fn manifestless_session_does_not_create_trace_files() {
let _home = TestHome::new();
let session_id = "plain-session";
unsafe {
let session = CString::new(session_id).unwrap();
let adapter = ad_adapter_create_with_session(session.as_ptr());
assert!(!adapter.is_null());
let _ = ad_check_permissions(adapter);
ad_adapter_destroy(adapter);
}
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));
with_isolated_home(|| {
let session_id = "plain-session";
unsafe {
let session = CString::new(session_id).unwrap();
let adapter = ad_adapter_create_with_session(session.as_ptr());
assert!(!adapter.is_null());
let _ = ad_check_permissions(adapter);
ad_adapter_destroy(adapter);
}
assert!(!trace_dir(session_id).unwrap().exists());
});
}

View file

@ -0,0 +1,29 @@
#![cfg(target_os = "windows")]
mod common;
use common::{ad_adapter_create, ad_adapter_destroy};
#[test]
fn adapter_create_without_ad_init_establishes_the_process_wide_mta() {
assert!(
!agent_desktop_windows::is_mta_established_for_new_threads(),
"no COM apartment may exist at library load, before the first adapter is created"
);
unsafe {
let adapter = ad_adapter_create();
assert!(
!adapter.is_null(),
"ad_adapter_create must succeed without any prior ad_init call"
);
assert!(
agent_desktop_windows::is_mta_established_for_new_threads(),
"constructing an adapter through the C ABI must establish the process-wide MTA"
);
ad_adapter_destroy(adapter);
}
assert!(
agent_desktop_windows::is_mta_established_for_new_threads(),
"the MTA usage cookie is retained for process lifetime and survives adapter destruction"
);
}

View file

@ -0,0 +1,104 @@
#![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;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
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)
);
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 entries_under(root: &Path) -> Vec<PathBuf> {
let mut entries = Vec::new();
let mut pending = vec![root.to_path_buf()];
while let Some(directory) = pending.pop() {
let Ok(read) = std::fs::read_dir(&directory) else {
continue;
};
for entry in read.flatten() {
let path = entry.path();
if entry.file_type().is_ok_and(|file_type| file_type.is_dir()) {
pending.push(path.clone());
}
entries.push(path);
}
}
entries
}
/// 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 = entries_under(&target);
assert!(
leaked.is_empty(),
"no session artifact — file or directory — may land under the junction target: {leaked:?}"
);
});
}

View file

@ -9,5 +9,16 @@ publish = false
agent-desktop-core.workspace = true
thiserror.workspace = true
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_System_Com",
"Win32_UI_HiDpi",
"Win32_Storage_FileSystem",
"Win32_Security",
"Win32_Security_Authorization",
"Win32_System_Threading",
] }
[lints]
workspace = true

View file

@ -1,4 +1,4 @@
use agent_desktop_core::{ActionOps, InputOps, ObservationOps, SystemOps};
use agent_desktop_core::{ActionOps, InputOps, ObservationOps};
pub struct WindowsAdapter;
@ -17,12 +17,11 @@ impl Default for WindowsAdapter {
impl ObservationOps for WindowsAdapter {}
impl ActionOps for WindowsAdapter {}
impl InputOps for WindowsAdapter {}
impl SystemOps for WindowsAdapter {}
#[cfg(test)]
mod tests {
use super::*;
use agent_desktop_core::{AppError, CommandContext, ErrorCode, SnapshotSurface};
use agent_desktop_core::{AppError, CommandContext, ErrorCode, SnapshotSurface, SystemOps};
#[test]
fn snapshot_surfaces_fail_closed_until_windows_implements_them() {

View file

@ -7,3 +7,11 @@ mod system;
mod tree;
pub use adapter::WindowsAdapter;
#[cfg(target_os = "windows")]
pub use system::com_runtime::bootstrap_hosted_library;
pub use system::com_runtime::{
ensure_hosted_library_mta_and_dpi, ensure_owned_process_mta_and_dpi,
is_mta_established_for_new_threads,
};
#[cfg(target_os = "windows")]
pub use system::private_file::WindowsPrivateFile;

View file

@ -0,0 +1,80 @@
use agent_desktop_core::{
AdapterError, AdapterSession, Deadline, InteractionLease, PermissionReport, SessionAffinity,
SystemOps,
};
use crate::adapter::WindowsAdapter;
impl SystemOps for WindowsAdapter {
fn permission_report(&self, deadline: Deadline) -> Result<PermissionReport, AdapterError> {
crate::system::permissions::report(deadline)
}
fn request_permissions(
&self,
lease: &InteractionLease,
) -> Result<PermissionReport, AdapterError> {
crate::system::permissions::request_report(lease.deadline())
}
fn unknown_accessibility_means_unsupported(&self) -> bool {
true
}
fn open_session(
&self,
_affinity: &SessionAffinity,
deadline: Deadline,
) -> Result<Box<dyn AdapterSession>, AdapterError> {
Ok(Box::new(crate::system::session::open(deadline)?))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unknown_accessibility_is_unsupported_so_cli_and_ffi_agree() {
use agent_desktop_core::PermissionState;
const UNRECOGNIZED_UIA_HRESULT: i32 = 0x8000_4005_u32 as i32;
let adapter = WindowsAdapter::new();
assert!(adapter.unknown_accessibility_means_unsupported());
assert_eq!(
crate::system::permissions::map_uia_access(UNRECOGNIZED_UIA_HRESULT),
PermissionState::Unknown
);
}
#[test]
fn open_session_returns_a_live_session_instead_of_not_supported() {
let affinity = SessionAffinity {
session_id: Some("windows-com-session".into()),
};
let session = WindowsAdapter::new()
.open_session(&affinity, Deadline::after(5_000).unwrap())
.expect("windows must open an adapter session instead of failing closed");
session.close().expect("a fresh session must close cleanly");
}
#[cfg(target_os = "windows")]
#[test]
fn permission_report_through_the_trait_probes_instead_of_defaulting() {
use agent_desktop_core::PermissionState;
let report =
SystemOps::permission_report(&WindowsAdapter::new(), Deadline::after(5_000).unwrap())
.unwrap();
assert_eq!(report.automation, PermissionState::NotRequired);
assert!(matches!(
report.accessibility,
PermissionState::Granted | PermissionState::Denied { .. }
));
}
}

View file

@ -0,0 +1,297 @@
use crate::system::dpi;
use agent_desktop_core::{AdapterError, ErrorCode};
use std::sync::OnceLock;
const S_OK_HRESULT: i32 = 0;
const S_FALSE_HRESULT: i32 = 1;
const RPC_E_CHANGED_MODE_HRESULT: i32 = 0x8001_0106_u32 as i32;
const CO_E_NOT_INITIALIZED_HRESULT: i32 = 0x8004_01F0_u32 as i32;
const APTTYPE_MTA_VALUE: i32 = 1;
#[cfg(target_os = "windows")]
const _: () = {
assert!(S_OK_HRESULT == windows_sys::Win32::Foundation::S_OK);
assert!(S_FALSE_HRESULT == windows_sys::Win32::Foundation::S_FALSE);
assert!(RPC_E_CHANGED_MODE_HRESULT == windows_sys::Win32::Foundation::RPC_E_CHANGED_MODE);
assert!(CO_E_NOT_INITIALIZED_HRESULT == windows_sys::Win32::Foundation::CO_E_NOTINITIALIZED);
assert!(APTTYPE_MTA_VALUE == windows_sys::Win32::System::Com::APTTYPE_MTA);
};
type RetainedMtaCookieAddress = usize;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ComApartment {
OwnedMta,
BorrowedFromHostMode,
}
impl ComApartment {
#[cfg(any(test, target_os = "windows"))]
pub(crate) fn permits_co_uninitialize(self) -> bool {
matches!(self, ComApartment::OwnedMta)
}
}
/// Joins the calling thread to the COM multithreaded apartment and applies
/// per-monitor-v2 DPI awareness, for a process this product owns (the CLI).
///
/// `CoInitializeEx` is thread-local, so the process-wide guard here is sound
/// only because the CLI calls this once from its main thread before any COM
/// work. `RPC_E_CHANGED_MODE` means another component already chose this
/// thread's apartment mode: the apartment is borrowed, the bootstrap
/// succeeds, and no `CoUninitialize` is ever scheduled for it.
pub fn ensure_owned_process_mta_and_dpi() -> Result<(), AdapterError> {
static OWNED_PROCESS_BOOTSTRAP: OnceLock<Result<ComApartment, AdapterError>> = OnceLock::new();
OWNED_PROCESS_BOOTSTRAP
.get_or_init(initialize_owned_process_apartment)
.clone()
.map(drop)
}
/// Registers process-wide MTA usage and applies per-monitor-v2 DPI awareness,
/// for library hosts (the cdylib) whose threads this product does not own.
///
/// Unlike the thread-local `CoInitializeEx`, `CoIncrementMTAUsage` acts on
/// the whole process, so a process-wide guard is exactly right: the call is
/// sound from any host thread, including an STA host's, and the returned
/// cookie is retained for the life of the process rather than released.
pub fn ensure_hosted_library_mta_and_dpi() -> Result<(), AdapterError> {
static HOSTED_LIBRARY_BOOTSTRAP: OnceLock<Result<RetainedMtaCookieAddress, AdapterError>> =
OnceLock::new();
HOSTED_LIBRARY_BOOTSTRAP
.get_or_init(initialize_hosted_library_apartment)
.clone()
.map(drop)
}
/// Performs the whole hosted-library bootstrap the cdylib needs before it
/// builds an adapter: joins the process-wide MTA, applies per-monitor-v2 DPI
/// awareness, and installs the Windows private-file backend into core.
///
/// The CLI installs the private-file backend from `main` before it parses, but
/// the cdylib has no such entry point, so it performs all three steps here at
/// `build_adapter` time.
#[cfg(target_os = "windows")]
pub fn bootstrap_hosted_library() -> Result<(), AdapterError> {
ensure_hosted_library_mta_and_dpi()?;
let _ = agent_desktop_core::install_private_file_ops(Box::new(crate::WindowsPrivateFile));
Ok(())
}
/// Reports whether a newly spawned thread that never called `CoInitializeEx`
/// observes membership in the multithreaded apartment, which becomes true
/// once the process-wide MTA exists. Read-only: `CoGetApartmentType` never
/// initializes COM, so probing cannot create the state it reports.
pub fn is_mta_established_for_new_threads() -> bool {
std::thread::Builder::new()
.name("agent-desktop-mta-probe".into())
.spawn(|| {
let (hresult, apartment_type) = imp::current_thread_apartment_type();
apartment_probe_reports_mta(hresult, apartment_type)
})
.ok()
.and_then(|probe| probe.join().ok())
.unwrap_or(false)
}
fn initialize_owned_process_apartment() -> Result<ComApartment, AdapterError> {
#[cfg(test)]
native_call_probe::OWNED_INITIALIZATIONS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let apartment =
classify_co_initialize_hresult(imp::co_initialize_multithreaded()).map_err(|hresult| {
com_bootstrap_failure(
"The COM multithreaded apartment could not be initialized",
hresult,
)
})?;
dpi::ensure_per_monitor_v2()?;
Ok(apartment)
}
fn initialize_hosted_library_apartment() -> Result<RetainedMtaCookieAddress, AdapterError> {
#[cfg(test)]
native_call_probe::HOSTED_INITIALIZATIONS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let (hresult, cookie_address) = imp::co_increment_mta_usage();
classify_mta_usage_hresult(hresult).map_err(|failure| {
com_bootstrap_failure(
"Process-wide COM MTA usage could not be registered",
failure,
)
})?;
dpi::ensure_per_monitor_v2()?;
Ok(cookie_address)
}
pub(crate) fn classify_co_initialize_hresult(hresult: i32) -> Result<ComApartment, i32> {
match hresult {
S_OK_HRESULT | S_FALSE_HRESULT => Ok(ComApartment::OwnedMta),
RPC_E_CHANGED_MODE_HRESULT => Ok(ComApartment::BorrowedFromHostMode),
failure => Err(failure),
}
}
pub(crate) fn classify_mta_usage_hresult(hresult: i32) -> Result<(), i32> {
if hresult >= 0 { Ok(()) } else { Err(hresult) }
}
pub(crate) fn apartment_probe_reports_mta(hresult: i32, apartment_type: i32) -> bool {
hresult >= 0 && apartment_type == APTTYPE_MTA_VALUE
}
fn com_bootstrap_failure(message: &str, hresult: i32) -> AdapterError {
AdapterError::new(ErrorCode::Internal, message)
.with_platform_detail(crate::system::permissions::com_hresult_detail(hresult))
.with_suggestion(
"Verify the host process allows COM initialization, then rerun the command",
)
}
#[cfg(target_os = "windows")]
mod imp {
use windows_sys::Win32::System::Com::{
APTTYPE, APTTYPEQUALIFIER, CO_MTA_USAGE_COOKIE, COINIT_MULTITHREADED, CoGetApartmentType,
CoIncrementMTAUsage, CoInitializeEx,
};
pub(super) fn co_initialize_multithreaded() -> i32 {
unsafe { CoInitializeEx(std::ptr::null(), COINIT_MULTITHREADED as u32) }
}
pub(super) fn co_increment_mta_usage() -> (i32, usize) {
let mut cookie: CO_MTA_USAGE_COOKIE = std::ptr::null_mut();
let hresult = unsafe { CoIncrementMTAUsage(&mut cookie) };
(hresult, cookie.addr())
}
pub(super) fn current_thread_apartment_type() -> (i32, i32) {
let mut apartment_type: APTTYPE = 0;
let mut qualifier: APTTYPEQUALIFIER = 0;
let hresult = unsafe { CoGetApartmentType(&mut apartment_type, &mut qualifier) };
(hresult, apartment_type)
}
}
#[cfg(not(target_os = "windows"))]
mod imp {
pub(super) fn co_initialize_multithreaded() -> i32 {
super::S_OK_HRESULT
}
pub(super) fn co_increment_mta_usage() -> (i32, usize) {
(super::S_OK_HRESULT, 0)
}
pub(super) fn current_thread_apartment_type() -> (i32, i32) {
(super::CO_E_NOT_INITIALIZED_HRESULT, 0)
}
}
#[cfg(test)]
mod native_call_probe {
use std::sync::atomic::AtomicU32;
pub(super) static OWNED_INITIALIZATIONS: AtomicU32 = AtomicU32::new(0);
pub(super) static HOSTED_INITIALIZATIONS: AtomicU32 = AtomicU32::new(0);
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::Ordering;
const E_OUTOFMEMORY_HRESULT: i32 = 0x8007_000E_u32 as i32;
const APTTYPE_STA_VALUE: i32 = 0;
#[test]
fn s_ok_establishes_an_owned_mta() {
assert_eq!(
classify_co_initialize_hresult(S_OK_HRESULT),
Ok(ComApartment::OwnedMta)
);
}
#[test]
fn s_false_means_the_thread_already_joined_and_stays_owned() {
assert_eq!(
classify_co_initialize_hresult(S_FALSE_HRESULT),
Ok(ComApartment::OwnedMta)
);
}
#[test]
fn rpc_e_changed_mode_is_borrowed_success_not_failure() {
assert_eq!(
classify_co_initialize_hresult(RPC_E_CHANGED_MODE_HRESULT),
Ok(ComApartment::BorrowedFromHostMode)
);
}
#[test]
fn a_borrowed_apartment_never_permits_co_uninitialize() {
assert!(!ComApartment::BorrowedFromHostMode.permits_co_uninitialize());
assert!(ComApartment::OwnedMta.permits_co_uninitialize());
}
#[test]
fn a_real_co_initialize_failure_stays_a_failure() {
assert_eq!(
classify_co_initialize_hresult(E_OUTOFMEMORY_HRESULT),
Err(E_OUTOFMEMORY_HRESULT)
);
}
#[test]
fn mta_usage_success_and_failure_split_on_hresult_sign() {
assert_eq!(classify_mta_usage_hresult(S_OK_HRESULT), Ok(()));
assert_eq!(
classify_mta_usage_hresult(E_OUTOFMEMORY_HRESULT),
Err(E_OUTOFMEMORY_HRESULT)
);
}
#[test]
fn owned_bootstrap_twice_succeeds_with_one_native_initialization() {
ensure_owned_process_mta_and_dpi().expect("first owned-process bootstrap");
ensure_owned_process_mta_and_dpi().expect("second owned-process bootstrap");
assert_eq!(
native_call_probe::OWNED_INITIALIZATIONS.load(Ordering::SeqCst),
1
);
}
#[test]
fn hosted_bootstrap_twice_succeeds_with_one_native_registration() {
ensure_hosted_library_mta_and_dpi().expect("first hosted-library bootstrap");
ensure_hosted_library_mta_and_dpi().expect("second hosted-library bootstrap");
assert_eq!(
native_call_probe::HOSTED_INITIALIZATIONS.load(Ordering::SeqCst),
1
);
}
#[test]
fn the_probe_requires_mta_membership_not_just_initialized_com() {
assert!(apartment_probe_reports_mta(S_OK_HRESULT, APTTYPE_MTA_VALUE));
assert!(!apartment_probe_reports_mta(
S_OK_HRESULT,
APTTYPE_STA_VALUE
));
assert!(!apartment_probe_reports_mta(
CO_E_NOT_INITIALIZED_HRESULT,
0
));
}
#[cfg(target_os = "windows")]
#[test]
fn an_established_mta_is_visible_to_fresh_threads() {
ensure_hosted_library_mta_and_dpi().expect("hosted-library bootstrap");
assert!(is_mta_established_for_new_threads());
}
#[cfg(not(target_os = "windows"))]
#[test]
fn the_canned_probe_reports_no_apartment_off_windows() {
assert!(!is_mta_established_for_new_threads());
}
}

View file

@ -0,0 +1,109 @@
use agent_desktop_core::{AdapterError, ErrorCode};
const DPI_CONTEXT_ALREADY_SET_ERROR: u32 = 5;
#[cfg(target_os = "windows")]
const _: () =
assert!(DPI_CONTEXT_ALREADY_SET_ERROR == windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DpiAwarenessOutcome {
PerMonitorV2Applied,
AlreadySet,
}
/// Applies `DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2` to this process,
/// judged on the call's return alone: awareness is never read back, because
/// `GetProcessDpiAwareness` has no V2 enumerant and reports V2 as V1.
/// `ERROR_ACCESS_DENIED` means the process or its host already fixed the
/// awareness context, which is success, not failure.
pub(crate) fn ensure_per_monitor_v2() -> Result<DpiAwarenessOutcome, AdapterError> {
let (call_succeeded, last_error) = imp::set_process_per_monitor_v2();
classify_dpi_awareness_call(call_succeeded, last_error).map_err(dpi_awareness_failure)
}
pub(crate) fn classify_dpi_awareness_call(
call_succeeded: bool,
last_error: u32,
) -> Result<DpiAwarenessOutcome, u32> {
match (call_succeeded, last_error) {
(true, _) => Ok(DpiAwarenessOutcome::PerMonitorV2Applied),
(false, DPI_CONTEXT_ALREADY_SET_ERROR) => Ok(DpiAwarenessOutcome::AlreadySet),
(false, failure) => Err(failure),
}
}
fn dpi_awareness_failure(last_error: u32) -> AdapterError {
AdapterError::new(
ErrorCode::Internal,
"Per-monitor-v2 DPI awareness could not be established for this process",
)
.with_platform_detail(format!(
"SetProcessDpiAwarenessContext Win32 error {last_error}"
))
.with_suggestion(
"Rerun from a process whose host has not locked an incompatible DPI awareness context",
)
}
#[cfg(target_os = "windows")]
mod imp {
use windows_sys::Win32::Foundation::GetLastError;
use windows_sys::Win32::UI::HiDpi::{
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, SetProcessDpiAwarenessContext,
};
pub(super) fn set_process_per_monitor_v2() -> (bool, u32) {
let call_succeeded =
unsafe { SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) }
!= 0;
if call_succeeded {
(true, 0)
} else {
(false, unsafe { GetLastError() })
}
}
}
#[cfg(not(target_os = "windows"))]
mod imp {
pub(super) fn set_process_per_monitor_v2() -> (bool, u32) {
(true, 0)
}
}
#[cfg(test)]
mod tests {
use super::*;
const ERROR_INVALID_PARAMETER_CODE: u32 = 87;
#[test]
fn a_successful_call_applies_per_monitor_v2() {
assert_eq!(
classify_dpi_awareness_call(true, 0),
Ok(DpiAwarenessOutcome::PerMonitorV2Applied)
);
}
#[test]
fn access_denied_means_awareness_was_already_decided_and_is_success() {
assert_eq!(
classify_dpi_awareness_call(false, DPI_CONTEXT_ALREADY_SET_ERROR),
Ok(DpiAwarenessOutcome::AlreadySet)
);
}
#[test]
fn other_win32_failures_stay_failures() {
assert_eq!(
classify_dpi_awareness_call(false, ERROR_INVALID_PARAMETER_CODE),
Err(ERROR_INVALID_PARAMETER_CODE)
);
}
#[test]
fn ensure_succeeds_whether_fresh_or_already_configured() {
ensure_per_monitor_v2().expect("the DPI bootstrap must succeed on every host lane");
}
}

View file

@ -1 +1,7 @@
mod adapter;
pub(crate) mod com_runtime;
pub(crate) mod dpi;
pub(crate) mod permissions;
#[cfg(target_os = "windows")]
pub(crate) mod private_file;
pub(crate) mod session;

View file

@ -0,0 +1,159 @@
use agent_desktop_core::{AdapterError, Deadline, PermissionReport, PermissionState};
const ACCESSIBILITY_SUGGESTION: &str = "Run agent-desktop in an interactive desktop session as a user allowed to use the UI Automation COM runtime; restricted tokens and AppContainer processes are denied UIA access.";
const S_OK: i32 = 0;
const E_ACCESSDENIED: i32 = 0x8007_0005_u32 as i32;
#[cfg(target_os = "windows")]
mod imp {
use windows_sys::Win32::System::Com::{
CLSCTX_INPROC_SERVER, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx,
CoUninitialize,
};
use windows_sys::core::{GUID, IID_IUnknown, IUnknown_Vtbl};
use crate::system::com_runtime::classify_co_initialize_hresult;
const CLSID_CUIAUTOMATION: GUID = GUID::from_u128(0xff48dba4_60ef_4201_aa87_54103eef594e);
pub(super) fn probe_uia_access() -> i32 {
unsafe {
let init_status = CoInitializeEx(core::ptr::null(), COINIT_MULTITHREADED as u32);
let apartment = match classify_co_initialize_hresult(init_status) {
Ok(apartment) => apartment,
Err(failure) => return failure,
};
let mut instance: *mut core::ffi::c_void = core::ptr::null_mut();
let create_status = CoCreateInstance(
&CLSID_CUIAUTOMATION,
core::ptr::null_mut(),
CLSCTX_INPROC_SERVER,
&IID_IUnknown,
&mut instance,
);
release_instance(instance);
if apartment.permits_co_uninitialize() {
CoUninitialize();
}
create_status
}
}
unsafe fn release_instance(instance: *mut core::ffi::c_void) {
if instance.is_null() {
return;
}
let vtable = unsafe { *instance.cast::<*const IUnknown_Vtbl>() };
unsafe {
((*vtable).Release)(instance);
}
}
pub(super) fn probe_capture_availability() -> Option<bool> {
None
}
}
#[cfg(not(target_os = "windows"))]
mod imp {
const E_NOTIMPL: i32 = 0x8000_4001_u32 as i32;
pub(super) fn probe_uia_access() -> i32 {
E_NOTIMPL
}
pub(super) fn probe_capture_availability() -> Option<bool> {
None
}
}
pub(crate) fn report(deadline: Deadline) -> Result<PermissionReport, AdapterError> {
ensure_budget(deadline)?;
report_from_probed_uia(deadline, imp::probe_uia_access())
}
pub(crate) fn request_report(deadline: Deadline) -> Result<PermissionReport, AdapterError> {
request_report_with(deadline, imp::probe_uia_access, report_from_probed_uia)
}
fn request_report_with(
deadline: Deadline,
probe: impl FnOnce() -> i32,
report: impl FnOnce(Deadline, i32) -> Result<PermissionReport, AdapterError>,
) -> Result<PermissionReport, AdapterError> {
ensure_budget(deadline)?;
let hresult = probe();
ensure_budget(deadline)?;
if matches!(map_uia_access(hresult), PermissionState::Denied { .. }) {
return Err(uia_access_denied_error(hresult));
}
report(deadline, hresult)
}
fn report_from_probed_uia(
deadline: Deadline,
uia_hresult: i32,
) -> Result<PermissionReport, AdapterError> {
let report = PermissionReport {
accessibility: map_uia_access(uia_hresult),
screen_recording: screen_recording_report_state(),
automation: automation_report_state(),
};
ensure_budget(deadline)?;
Ok(report)
}
pub(crate) fn map_uia_access(hresult: i32) -> PermissionState {
match hresult {
S_OK => PermissionState::Granted,
E_ACCESSDENIED => PermissionState::Denied {
suggestion: ACCESSIBILITY_SUGGESTION.into(),
},
_ => PermissionState::Unknown,
}
}
pub(crate) fn map_capture_availability(availability: Option<bool>) -> PermissionState {
match availability {
Some(true) => PermissionState::NotRequired,
Some(false) | None => PermissionState::Unknown,
}
}
pub(crate) fn uia_access_denied_error(hresult: i32) -> AdapterError {
AdapterError::new(
agent_desktop_core::ErrorCode::PermDenied,
"UI Automation access is denied for this process",
)
.with_suggestion(ACCESSIBILITY_SUGGESTION)
.with_platform_detail(com_hresult_detail(hresult))
}
pub(crate) fn com_hresult_detail(hresult: i32) -> String {
let code = hresult as u32;
match hresult {
E_ACCESSDENIED => format!("COM HRESULT 0x{code:08X} (E_ACCESSDENIED: Access is denied)"),
_ => format!("COM HRESULT 0x{code:08X}"),
}
}
fn screen_recording_report_state() -> PermissionState {
map_capture_availability(imp::probe_capture_availability())
}
fn automation_report_state() -> PermissionState {
PermissionState::NotRequired
}
pub(crate) fn ensure_budget(deadline: Deadline) -> Result<(), AdapterError> {
if deadline.is_expired() {
Err(deadline.timeout_error())
} else {
Ok(())
}
}
#[cfg(test)]
#[path = "permissions_tests.rs"]
mod tests;

View file

@ -0,0 +1,132 @@
use super::*;
#[test]
fn expired_permission_deadline_fails_without_native_calls() {
let error = report(Deadline::after(0).unwrap()).unwrap_err();
assert_eq!(error.code, agent_desktop_core::ErrorCode::Timeout);
}
#[test]
fn uia_access_grant_and_denial_map_from_literal_hresults() {
assert_eq!(map_uia_access(0), PermissionState::Granted);
let PermissionState::Denied { suggestion } = map_uia_access(0x8007_0005_u32 as i32) else {
panic!("E_ACCESSDENIED must map to a denial");
};
assert!(!suggestion.is_empty());
}
#[test]
fn unrecognised_hresults_map_to_unknown_never_a_guess() {
assert_eq!(map_uia_access(1), PermissionState::Unknown);
assert_eq!(map_uia_access(-1), PermissionState::Unknown);
assert_eq!(
map_uia_access(0x8000_4005_u32 as i32),
PermissionState::Unknown
);
assert_eq!(
map_uia_access(0x8001_0106_u32 as i32),
PermissionState::Unknown
);
}
#[test]
fn capture_availability_maps_only_what_is_wired_today() {
assert_eq!(map_capture_availability(None), PermissionState::Unknown);
assert_eq!(
map_capture_availability(Some(true)),
PermissionState::NotRequired
);
assert_eq!(
map_capture_availability(Some(false)),
PermissionState::Unknown
);
}
#[test]
fn denial_platform_detail_matches_the_invariant_hresult_format() {
let error = uia_access_denied_error(0x8007_0005_u32 as i32);
assert_eq!(error.code, agent_desktop_core::ErrorCode::PermDenied);
assert_eq!(
error.platform_detail.as_deref(),
Some("COM HRESULT 0x80070005 (E_ACCESSDENIED: Access is denied)")
);
assert!(
error
.suggestion
.is_some_and(|suggestion| !suggestion.is_empty())
);
}
#[test]
fn unnamed_hresults_format_without_inventing_a_name() {
assert_eq!(
com_hresult_detail(0x8000_4005_u32 as i32),
"COM HRESULT 0x80004005"
);
}
#[test]
fn request_on_a_denied_probe_is_a_structured_error_not_a_prompt() {
let error = request_report_with(
Deadline::after(1_000).unwrap(),
|| 0x8007_0005_u32 as i32,
|_, _| panic!("a denied probe must not fall through to the report"),
)
.unwrap_err();
assert_eq!(error.code, agent_desktop_core::ErrorCode::PermDenied);
assert!(error.platform_detail.is_some());
}
#[test]
fn request_on_a_granted_probe_reports_accessibility_from_that_single_probe() {
let report = request_report_with(
Deadline::after(1_000).unwrap(),
|| 0,
report_from_probed_uia,
)
.unwrap();
assert_eq!(report.accessibility, PermissionState::Granted);
assert_eq!(report.automation, PermissionState::NotRequired);
}
#[cfg(not(target_os = "windows"))]
#[test]
fn non_windows_arm_reports_the_canned_default_shape() {
let report = report(Deadline::after(1_000).unwrap()).unwrap();
assert_eq!(report.accessibility, PermissionState::Unknown);
assert_eq!(report.screen_recording, PermissionState::Unknown);
assert_eq!(report.automation, PermissionState::NotRequired);
}
#[cfg(target_os = "windows")]
#[test]
fn automation_is_not_required_on_windows() {
let report = report(Deadline::after(5_000).unwrap()).unwrap();
assert_eq!(report.automation, PermissionState::NotRequired);
}
#[cfg(target_os = "windows")]
#[test]
fn screen_recording_is_unknown_until_a_capture_api_is_wired() {
let report = report(Deadline::after(5_000).unwrap()).unwrap();
assert_eq!(report.screen_recording, PermissionState::Unknown);
}
#[cfg(target_os = "windows")]
#[test]
fn uia_probe_reaches_a_verdict_on_a_healthy_windows_session() {
let report = report(Deadline::after(5_000).unwrap()).unwrap();
assert!(matches!(
report.accessibility,
PermissionState::Granted | PermissionState::Denied { .. }
));
}

View file

@ -0,0 +1,186 @@
//! Storage locality classification for private-artifact write surfaces.
//!
//! `GetFileInformationByHandleEx(FileRemoteProtocolInfo)` (class 13) signals a
//! local volume by failing with `ERROR_INVALID_PARAMETER` (87): measured, zero
//! of six local targets returned data while three of three remote targets did.
//! The trap is that an out-of-range class returns the same 87, so 87 counts
//! as a locality signal only behind a control call — `FileBasicInfo` (class 0)
//! must first succeed on the same handle to prove the call plumbing.
//!
//! If the control call fails the verdict is `Unknown`, and `Unknown` is
//! refused for private-artifact writes: failing open would stream private
//! data to SMB storage on a redirected profile. Reads are never gated by
//! locality — the deleted v0.5.0 layer's locality check killed `status` on
//! ordinary local disk, which is why the control-call discipline exists and
//! why only write surfaces consult this module.
use std::fs::File;
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Foundation::{ERROR_INVALID_PARAMETER, HANDLE};
use windows_sys::Win32::Storage::FileSystem::{
FILE_BASIC_INFO, FILE_REMOTE_PROTOCOL_INFO, FileBasicInfo, FileRemoteProtocolInfo,
GetFileInformationByHandleEx,
};
use super::permission_denied;
const FILE_BASIC_INFO_SIZE: usize = 40;
const _: () = assert!(size_of::<FILE_BASIC_INFO>() == FILE_BASIC_INFO_SIZE);
pub(super) const FILE_REMOTE_PROTOCOL_INFO_SIZE: usize = 116;
const _: () = assert!(size_of::<FILE_REMOTE_PROTOCOL_INFO>() == FILE_REMOTE_PROTOCOL_INFO_SIZE);
/// The kernel validates the output buffer's alignment before dispatching the
/// information class: a 4-aligned `FILE_REMOTE_PROTOCOL_INFO` fails with
/// `ERROR_NOACCESS` (998) instead of reaching the class handler that returns
/// the measured 87, so the probe buffer is forced to 8-byte alignment while
/// the byte count stays the measured 116.
#[repr(C, align(8))]
pub(super) struct AlignedRemoteProtocolInfo(pub(super) FILE_REMOTE_PROTOCOL_INFO);
const _: () = assert!(size_of::<AlignedRemoteProtocolInfo>() == 120);
impl AlignedRemoteProtocolInfo {
pub(super) fn zeroed() -> Self {
Self(FILE_REMOTE_PROTOCOL_INFO::default())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum SurfaceLocality {
Local,
Remote,
Unknown,
}
pub(super) fn require_local_for_private_write(file: &File, what: &str) -> std::io::Result<()> {
match assess_file_locality(file) {
SurfaceLocality::Local => Ok(()),
SurfaceLocality::Remote => Err(permission_denied(format!(
"{what} resides on remote storage; private artifacts must stay on local disk"
))),
SurfaceLocality::Unknown => Err(permission_denied(format!(
"{what} locality could not be determined; refusing to write private artifacts to it"
))),
}
}
pub(super) fn assess_file_locality(file: &File) -> SurfaceLocality {
let control_succeeded = basic_info_control_succeeds(control_probe_handle(file));
let remote_probe = remote_protocol_probe_result(file);
classify_surface_locality(control_succeeded, remote_probe)
}
fn remote_protocol_probe_result(file: &File) -> Result<(), u32> {
#[cfg(test)]
if forced_remote_locality::is_active() {
return Ok(());
}
remote_protocol_probe(file.as_raw_handle())
}
pub(super) fn classify_surface_locality(
control_succeeded: bool,
remote_probe: Result<(), u32>,
) -> SurfaceLocality {
match (control_succeeded, remote_probe) {
(false, _) => SurfaceLocality::Unknown,
(true, Ok(())) => SurfaceLocality::Remote,
(true, Err(ERROR_INVALID_PARAMETER)) => SurfaceLocality::Local,
(true, Err(_)) => SurfaceLocality::Unknown,
}
}
pub(super) fn basic_info_control_succeeds(handle: HANDLE) -> bool {
let mut information = FILE_BASIC_INFO::default();
let succeeded = unsafe {
GetFileInformationByHandleEx(
handle,
FileBasicInfo,
std::ptr::from_mut(&mut information).cast(),
FILE_BASIC_INFO_SIZE as u32,
)
};
succeeded != 0
}
pub(super) fn remote_protocol_probe(handle: HANDLE) -> Result<(), u32> {
let mut information = AlignedRemoteProtocolInfo::zeroed();
let succeeded = unsafe {
GetFileInformationByHandleEx(
handle,
FileRemoteProtocolInfo,
std::ptr::from_mut(&mut information).cast(),
FILE_REMOTE_PROTOCOL_INFO_SIZE as u32,
)
};
if succeeded != 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error()
.raw_os_error()
.unwrap_or_default() as u32)
}
}
fn control_probe_handle(file: &File) -> HANDLE {
#[cfg(test)]
if forced_control_failure::is_active() {
return windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
}
file.as_raw_handle()
}
#[cfg(test)]
pub(super) mod forced_control_failure {
use std::cell::Cell;
thread_local! {
static FORCE_CONTROL_FAILURE: Cell<bool> = const { Cell::new(false) };
}
pub(in super::super) fn is_active() -> bool {
FORCE_CONTROL_FAILURE.with(Cell::get)
}
pub(in super::super) fn with_forced_control_failure<R>(run: impl FnOnce() -> R) -> R {
struct ResetOnDrop;
impl Drop for ResetOnDrop {
fn drop(&mut self) {
FORCE_CONTROL_FAILURE.with(|flag| flag.set(false));
}
}
FORCE_CONTROL_FAILURE.with(|flag| flag.set(true));
let _reset = ResetOnDrop;
run()
}
}
/// Forces the remote-protocol probe to report success while the class-0
/// control call still runs for real, so the classifier yields `Remote` and
/// the remote-storage refusal branch can be exercised on ordinary local disk.
#[cfg(test)]
pub(super) mod forced_remote_locality {
use std::cell::Cell;
thread_local! {
static FORCE_REMOTE_LOCALITY: Cell<bool> = const { Cell::new(false) };
}
pub(in super::super) fn is_active() -> bool {
FORCE_REMOTE_LOCALITY.with(Cell::get)
}
pub(in super::super) fn with_forced_remote_locality<R>(run: impl FnOnce() -> R) -> R {
struct ResetOnDrop;
impl Drop for ResetOnDrop {
fn drop(&mut self) {
FORCE_REMOTE_LOCALITY.with(|flag| flag.set(false));
}
}
FORCE_REMOTE_LOCALITY.with(|flag| flag.set(true));
let _reset = ResetOnDrop;
run()
}
}

View file

@ -0,0 +1,145 @@
use super::Scratch;
use crate::system::private_file::WindowsPrivateFile;
use crate::system::private_file::locality::{
AlignedRemoteProtocolInfo, FILE_REMOTE_PROTOCOL_INFO_SIZE, SurfaceLocality,
assess_file_locality, basic_info_control_succeeds, classify_surface_locality,
forced_control_failure::with_forced_control_failure,
forced_remote_locality::with_forced_remote_locality, remote_protocol_probe,
};
use agent_desktop_core::PrivateFileOps;
use std::io::ErrorKind;
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Foundation::ERROR_INVALID_PARAMETER;
use windows_sys::Win32::Storage::FileSystem::GetFileInformationByHandleEx;
const OUT_OF_RANGE_INFO_CLASS: i32 = 55;
#[test]
fn the_classifier_requires_a_succeeding_control_call_before_87_means_local() {
assert_eq!(
classify_surface_locality(true, Err(ERROR_INVALID_PARAMETER)),
SurfaceLocality::Local
);
assert_eq!(
classify_surface_locality(true, Ok(())),
SurfaceLocality::Remote
);
assert_eq!(
classify_surface_locality(false, Err(ERROR_INVALID_PARAMETER)),
SurfaceLocality::Unknown
);
assert_eq!(
classify_surface_locality(true, Err(5)),
SurfaceLocality::Unknown
);
}
#[test]
fn class_13_fails_87_on_a_local_ntfs_file_while_the_control_class_succeeds_on_the_same_handle() {
let scratch = Scratch::new("locality-local");
let path = scratch.path().join("target.bin");
std::fs::write(&path, b"local bytes").unwrap();
let file = std::fs::File::open(&path).unwrap();
assert!(
basic_info_control_succeeds(file.as_raw_handle()),
"the FileBasicInfo control call must succeed on a local handle"
);
assert_eq!(
remote_protocol_probe(file.as_raw_handle()),
Err(ERROR_INVALID_PARAMETER),
"class 13 must fail with 87 on local NTFS"
);
assert_eq!(assess_file_locality(&file), SurfaceLocality::Local);
}
#[test]
fn an_out_of_range_class_returns_the_same_87_so_87_alone_proves_nothing() {
let scratch = Scratch::new("locality-out-of-range");
let path = scratch.path().join("target.bin");
std::fs::write(&path, b"local bytes").unwrap();
let file = std::fs::File::open(&path).unwrap();
let mut information = AlignedRemoteProtocolInfo::zeroed();
let succeeded = unsafe {
GetFileInformationByHandleEx(
file.as_raw_handle(),
OUT_OF_RANGE_INFO_CLASS,
std::ptr::from_mut(&mut information).cast(),
FILE_REMOTE_PROTOCOL_INFO_SIZE as u32,
)
};
assert_eq!(succeeded, 0, "an out-of-range class must fail");
assert_eq!(
std::io::Error::last_os_error().raw_os_error(),
Some(ERROR_INVALID_PARAMETER as i32),
"the out-of-range failure must be the same 87 the locality probe sees"
);
}
#[test]
fn a_forced_control_failure_yields_unknown_and_the_private_write_is_refused() {
let scratch = Scratch::new("locality-unknown");
let probe_path = scratch.path().join("probe.bin");
std::fs::write(&probe_path, b"probe").unwrap();
let probe_file = std::fs::File::open(&probe_path).unwrap();
let refused_artifact = scratch.path().join("gated-parent").join("artifact.json");
with_forced_control_failure(|| {
assert_eq!(assess_file_locality(&probe_file), SurfaceLocality::Unknown);
let refused = WindowsPrivateFile::new()
.write_atomic(&refused_artifact, b"secret")
.unwrap_err();
assert_eq!(refused.kind(), ErrorKind::PermissionDenied);
assert!(
refused
.to_string()
.contains("locality could not be determined"),
"the refusal must name the unknown locality: {refused}"
);
});
assert!(
!refused_artifact.exists(),
"no artifact may exist after the refused write"
);
WindowsPrivateFile::new()
.write_atomic(
&scratch.path().join("ungated-parent").join("artifact.json"),
b"local again",
)
.expect("writes must work again once the control call is restored");
}
#[test]
fn a_forced_remote_locality_refuses_the_private_write_while_the_control_call_succeeds() {
let scratch = Scratch::new("locality-remote");
let probe_path = scratch.path().join("probe.bin");
std::fs::write(&probe_path, b"probe").unwrap();
let probe_file = std::fs::File::open(&probe_path).unwrap();
let refused_artifact = scratch.path().join("gated-parent").join("artifact.json");
with_forced_remote_locality(|| {
assert_eq!(assess_file_locality(&probe_file), SurfaceLocality::Remote);
let refused = WindowsPrivateFile::new()
.write_atomic(&refused_artifact, b"secret")
.unwrap_err();
assert_eq!(refused.kind(), ErrorKind::PermissionDenied);
assert!(
refused.to_string().contains("remote storage"),
"the refusal must name the remote storage: {refused}"
);
});
assert!(
!refused_artifact.exists(),
"no artifact may exist after the refused write"
);
WindowsPrivateFile::new()
.write_atomic(
&scratch.path().join("ungated-parent").join("artifact.json"),
b"local again",
)
.expect("writes must work again once the locality probe is restored");
}

View file

@ -0,0 +1,139 @@
//! Windows hardening for core's five private-file primitives.
//!
//! Four measured behaviors drive four modules: per-component reparse-point
//! rejection (`path`), `ReplaceFileW`-based atomic promotion with a
//! write-scoped temp lease (`replace`), `TokenOwner` foreign-principal
//! detection (`owner`), and control-call-disciplined storage locality
//! (`locality`). Each override mirrors the portable default's observable
//! semantics — parent handling, create/append/lock open modes, the hashed
//! nonce temp naming, and the bounded-read limits — and adds only the
//! hardening the measured evidence justifies.
//!
//! Deliberately absent: descriptor authoring and DACL validation. A freshly
//! created leaf under the user profile inherits its parent's ACL —
//! `NT AUTHORITY\SYSTEM`, `BUILTIN\Administrators`, and the user, with no
//! `BUILTIN\Users` — while a `ReplaceFileW` rewrite instead keeps the DACL of
//! the leaf it replaces: for our own artifacts the same SYSTEM/Administrators/
//! user grants with no `BUILTIN\Users`, though re-materialized as explicit
//! ACEs rather than inherited ones. Either way the security-relevant grants
//! carry across, so authoring would re-state what Windows grants and
//! validating would re-check it with exactly the ACE-parsing code whose
//! `AceSize` handling sank the deleted v0.5.0 layer. Nothing here calls the
//! ACL/ACE family, a test pins that absence, and a structural test pins the
//! inherited grants on the freshly created leaf and the same principals'
//! survival across the replace path so an OS change breaks a test rather than
//! the product.
//!
//! Locality gates only the write surfaces (atomic writes, appends, lock
//! opens); reads stay ungated so observation commands keep working wherever
//! a readable artifact lives. Ownership checks compare against `TokenOwner`,
//! never `TokenUser`, and detect pre-creation by a foreign principal — they
//! are not, and cannot be, an isolation boundary between administrators.
mod locality;
mod owner;
mod path;
mod replace;
use std::fs::{File, OpenOptions};
use std::io::{ErrorKind, Write};
use std::path::Path;
use agent_desktop_core::{PrivateFileOps, bounded_read};
/// Windows implementation of core's private-file seam, installed once per
/// process by the binary and FFI entry points.
#[derive(Debug, Default)]
pub struct WindowsPrivateFile;
impl WindowsPrivateFile {
#[must_use]
pub fn new() -> Self {
Self
}
}
impl PrivateFileOps for WindowsPrivateFile {
fn write_atomic(&self, path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let parent = path
.parent()
.ok_or_else(|| invalid_input("private file path has no parent"))?;
let destination_name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| invalid_input("private file path has an invalid filename"))?;
path::ensure_private_directory_chain(parent)?;
validate_destination_if_present(path)?;
let lease = replace::acquire_write_lease(parent)?;
let (temporary, file) = replace::create_private_temp_file(&lease, destination_name)?;
write_all_and_sync(file, bytes)?;
replace::promote_temp_to_destination(path, &temporary)?;
validate_written_destination(path)
}
fn open_private_append(&self, path: &Path) -> std::io::Result<File> {
if let Some(parent) = path.parent() {
path::require_reparse_free_directory_chain(parent)?;
}
let mut options = OpenOptions::new();
options.read(true).create(true).append(true);
let file = path::open_leaf_regular_no_follow(path, &mut options, "private append target")?;
owner::require_owned_by_token_owner(&file, "private append target")?;
locality::require_local_for_private_write(&file, "private append target")?;
Ok(file)
}
fn open_private_lock(&self, path: &Path, create: bool) -> std::io::Result<File> {
let parent = path
.parent()
.ok_or_else(|| invalid_input("private file path has no parent"))?;
path::ensure_private_directory_chain(parent)?;
let mut options = OpenOptions::new();
options.read(true).write(true).create(create);
let file = path::open_leaf_regular_no_follow(path, &mut options, "private lock file")?;
owner::require_owned_by_token_owner(&file, "private lock file")?;
locality::require_local_for_private_write(&file, "private lock file")?;
Ok(file)
}
fn read_private_bounded(&self, path: &Path, max_bytes: u64) -> std::io::Result<Vec<u8>> {
let mut options = OpenOptions::new();
options.read(true);
let file = path::open_leaf_regular_no_follow(path, &mut options, "private file")?;
owner::require_owned_by_token_owner(&file, "private file")?;
bounded_read(file, max_bytes)
}
fn ensure_private(&self, path: &Path) -> std::io::Result<()> {
path::ensure_private_directory_chain(path)
}
}
fn validate_destination_if_present(path: &Path) -> std::io::Result<()> {
match path::open_leaf_for_validation(path, "private file destination") {
Ok(file) => owner::require_owned_by_token_owner(&file, "private file destination"),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
fn validate_written_destination(path: &Path) -> std::io::Result<()> {
let file = path::open_leaf_for_validation(path, "replaced private file")?;
owner::require_owned_by_token_owner(&file, "replaced private file")
}
fn write_all_and_sync(mut file: File, bytes: &[u8]) -> std::io::Result<()> {
file.write_all(bytes)?;
file.sync_all()
}
fn invalid_input(message: &'static str) -> std::io::Error {
std::io::Error::new(ErrorKind::InvalidData, message)
}
fn permission_denied(message: impl Into<String>) -> std::io::Error {
std::io::Error::new(ErrorKind::PermissionDenied, message.into())
}
#[cfg(test)]
mod tests;

View file

@ -0,0 +1,242 @@
//! Ownership validation against the process token's `TokenOwner`.
//!
//! New filesystem objects land owned by `TokenOwner`, not `TokenUser`:
//! measured at both High and Medium integrity, a file created by an
//! admin-group account is owned by the Administrators group while
//! `OwnerMatchesTokenUser` is false and `OwnerMatchesTokenOwner` is true —
//! group membership is the variable, never integrity. Validation therefore
//! compares against `TokenOwner` only.
//!
//! The purpose is narrow: detecting a path pre-created by a foreign
//! principal, the Windows analogue of the unix uid post-condition in core's
//! `private_file.rs`. It is explicitly not an isolation boundary between
//! administrator processes — an administrator holds
//! `SeTakeOwnershipPrivilege`, so no file-permission mechanism can exclude
//! one. The unix `nlink` post-condition has no measured Windows analogue and
//! is deliberately not carried here.
//!
//! Only the owner is read — `GetSecurityInfo` with
//! `OWNER_SECURITY_INFORMATION` and no DACL requested — so this module reads
//! a fixed-layout SID and never touches an ACE.
use std::fs::File;
use std::io::ErrorKind;
use std::os::windows::io::AsRawHandle;
use std::sync::OnceLock;
use windows_sys::Win32::Foundation::{CloseHandle, ERROR_INSUFFICIENT_BUFFER, HANDLE, LocalFree};
use windows_sys::Win32::Security::Authorization::{GetSecurityInfo, SE_FILE_OBJECT};
use windows_sys::Win32::Security::{
EqualSid, GetLengthSid, GetTokenInformation, IsValidSid, OWNER_SECURITY_INFORMATION, PSID,
SECURITY_MAX_SID_SIZE, TOKEN_INFORMATION_CLASS, TOKEN_OWNER, TOKEN_QUERY, TokenOwner,
};
use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
use super::permission_denied;
const TOKEN_OWNER_SIZE: usize = 8;
const _: () = assert!(size_of::<TOKEN_OWNER>() == TOKEN_OWNER_SIZE);
pub(super) struct SidBuffer {
storage: Vec<u64>,
}
impl SidBuffer {
pub(super) fn copied_from_valid(sid: PSID) -> std::io::Result<Self> {
if sid.is_null() || unsafe { IsValidSid(sid) } == 0 {
return Err(std::io::Error::new(
ErrorKind::InvalidData,
"the reported owner is not a valid SID",
));
}
let length = unsafe { GetLengthSid(sid) } as usize;
if length == 0 || length > SECURITY_MAX_SID_SIZE as usize {
return Err(std::io::Error::new(
ErrorKind::InvalidData,
"the reported owner SID has an impossible length",
));
}
let mut storage = vec![0_u64; length.div_ceil(size_of::<u64>())];
unsafe {
std::ptr::copy_nonoverlapping(
sid.cast::<u8>(),
storage.as_mut_ptr().cast::<u8>(),
length,
);
}
Ok(Self { storage })
}
pub(super) fn as_psid(&self) -> PSID {
self.storage.as_ptr().cast::<core::ffi::c_void>().cast_mut()
}
pub(super) fn matches(&self, other: &SidBuffer) -> bool {
unsafe { EqualSid(self.as_psid(), other.as_psid()) != 0 }
}
}
pub(super) fn require_owned_by_token_owner(file: &File, what: &str) -> std::io::Result<()> {
let expected = process_token_owner_sid()?;
let actual = actual_owner_for_comparison(file)?;
if !expected.matches(&actual) {
return Err(permission_denied(format!(
"{what} is owned by a foreign principal, not this process's token owner"
)));
}
Ok(())
}
fn actual_owner_for_comparison(file: &File) -> std::io::Result<SidBuffer> {
#[cfg(test)]
if forced_foreign_owner::is_active() {
return forced_foreign_owner::foreign_sid();
}
file_owner_sid(file)
}
pub(super) fn file_owner_sid(file: &File) -> std::io::Result<SidBuffer> {
let mut owner: PSID = std::ptr::null_mut();
let mut descriptor: *mut core::ffi::c_void = std::ptr::null_mut();
let status = unsafe {
GetSecurityInfo(
file.as_raw_handle(),
SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION,
&mut owner,
std::ptr::null_mut(),
std::ptr::null_mut(),
std::ptr::null_mut(),
&mut descriptor,
)
};
if status != 0 {
return Err(std::io::Error::from_raw_os_error(status as i32));
}
let copied = SidBuffer::copied_from_valid(owner);
if !descriptor.is_null() {
unsafe { LocalFree(descriptor) };
}
copied
}
pub(super) fn process_token_owner_sid() -> std::io::Result<&'static SidBuffer> {
static PROCESS_TOKEN_OWNER: OnceLock<Result<SidBuffer, String>> = OnceLock::new();
PROCESS_TOKEN_OWNER
.get_or_init(|| read_process_token_owner().map_err(|error| error.to_string()))
.as_ref()
.map_err(|message| std::io::Error::new(ErrorKind::PermissionDenied, message.clone()))
}
fn read_process_token_owner() -> std::io::Result<SidBuffer> {
let buffer = read_process_token_information(TokenOwner)?;
let owner: TOKEN_OWNER = unsafe { std::ptr::read(buffer.as_ptr().cast()) };
SidBuffer::copied_from_valid(owner.Owner)
}
fn read_process_token_information(class: TOKEN_INFORMATION_CLASS) -> std::io::Result<Vec<u64>> {
let mut token: HANDLE = std::ptr::null_mut();
let opened = unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) };
if opened == 0 {
return Err(std::io::Error::last_os_error());
}
let information = read_token_information(token, class);
unsafe { CloseHandle(token) };
information
}
fn read_token_information(
token: HANDLE,
class: TOKEN_INFORMATION_CLASS,
) -> std::io::Result<Vec<u64>> {
let mut required: u32 = 0;
let probed =
unsafe { GetTokenInformation(token, class, std::ptr::null_mut(), 0, &mut required) };
if probed != 0 || required == 0 {
return Err(std::io::Error::new(
ErrorKind::InvalidData,
"the process token reported no information payload",
));
}
let probe_error = std::io::Error::last_os_error();
if probe_error.raw_os_error() != Some(ERROR_INSUFFICIENT_BUFFER as i32) {
return Err(probe_error);
}
let mut buffer = vec![0_u64; (required as usize).div_ceil(size_of::<u64>())];
let fetched = unsafe {
GetTokenInformation(
token,
class,
buffer.as_mut_ptr().cast(),
required,
&mut required,
)
};
if fetched == 0 {
return Err(std::io::Error::last_os_error());
}
Ok(buffer)
}
#[cfg(test)]
pub(super) fn process_token_user_sid_for_tests() -> std::io::Result<SidBuffer> {
use windows_sys::Win32::Security::{TOKEN_USER, TokenUser};
const TOKEN_USER_SIZE: usize = 16;
const _: () = assert!(size_of::<TOKEN_USER>() == TOKEN_USER_SIZE);
let buffer = read_process_token_information(TokenUser)?;
let user: TOKEN_USER = unsafe { std::ptr::read(buffer.as_ptr().cast()) };
SidBuffer::copied_from_valid(user.User.Sid)
}
/// Forces the owner comparison to observe a foreign principal so the
/// refusal branch of `require_owned_by_token_owner` can be exercised without
/// a file actually pre-created by another account. The substituted owner is
/// the `WinLocalSystemSid`, built programmatically so the seam stays portable
/// and privilege-free.
#[cfg(test)]
pub(super) mod forced_foreign_owner {
use std::cell::Cell;
use windows_sys::Win32::Security::{
CreateWellKnownSid, SECURITY_MAX_SID_SIZE, WinLocalSystemSid,
};
use super::SidBuffer;
thread_local! {
static FORCE_FOREIGN_OWNER: Cell<bool> = const { Cell::new(false) };
}
pub(in super::super) fn is_active() -> bool {
FORCE_FOREIGN_OWNER.with(Cell::get)
}
pub(in super::super) fn foreign_sid() -> std::io::Result<SidBuffer> {
let mut storage = [0_u64; 9];
let mut size: u32 = SECURITY_MAX_SID_SIZE;
let created = unsafe {
CreateWellKnownSid(
WinLocalSystemSid,
std::ptr::null_mut(),
storage.as_mut_ptr().cast(),
&mut size,
)
};
if created == 0 {
return Err(std::io::Error::last_os_error());
}
SidBuffer::copied_from_valid(storage.as_mut_ptr().cast())
}
pub(in super::super) fn with_forced_foreign_owner<R>(run: impl FnOnce() -> R) -> R {
struct ResetOnDrop;
impl Drop for ResetOnDrop {
fn drop(&mut self) {
FORCE_FOREIGN_OWNER.with(|flag| flag.set(false));
}
}
FORCE_FOREIGN_OWNER.with(|flag| flag.set(true));
let _reset = ResetOnDrop;
run()
}
}

View file

@ -0,0 +1,242 @@
use super::{Scratch, scratch_nonce};
use crate::system::private_file::WindowsPrivateFile;
use crate::system::private_file::owner::forced_foreign_owner::with_forced_foreign_owner;
use crate::system::private_file::owner::{
SidBuffer, file_owner_sid, process_token_owner_sid, process_token_user_sid_for_tests,
require_owned_by_token_owner,
};
use agent_desktop_core::PrivateFileOps;
use std::io::ErrorKind;
use std::path::Path;
use windows_sys::Win32::Foundation::LocalFree;
use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW;
use windows_sys::Win32::Security::{
CreateWellKnownSid, SECURITY_MAX_SID_SIZE, WELL_KNOWN_SID_TYPE, WinBuiltinAdministratorsSid,
WinBuiltinUsersSid, WinLocalSystemSid,
};
#[test]
fn a_freshly_created_files_owner_equals_the_process_token_owner() {
let scratch = Scratch::new("owner-fresh");
let path = scratch.path().join("fresh.txt");
let file = std::fs::File::create(&path).unwrap();
let owner_sid = file_owner_sid(&file).unwrap();
let token_owner = process_token_owner_sid().unwrap();
assert!(
token_owner.matches(&owner_sid),
"a freshly created file must land owned by the token owner"
);
require_owned_by_token_owner(&file, "freshly created file").unwrap();
}
#[test]
fn the_token_owner_does_not_match_a_foreign_well_known_principal() {
let token_owner = process_token_owner_sid().unwrap();
let foreign = well_known_sid_buffer(WinLocalSystemSid);
assert!(
!token_owner.matches(&foreign),
"the LocalSystem principal must be foreign to this test process's token owner"
);
}
#[test]
fn require_owned_by_token_owner_refuses_a_foreign_owner_via_the_forced_seam() {
let scratch = Scratch::new("owner-foreign");
let path = scratch.path().join("fresh.txt");
let file = std::fs::File::create(&path).unwrap();
let refused = with_forced_foreign_owner(|| {
require_owned_by_token_owner(&file, "seam target").unwrap_err()
});
assert_eq!(refused.kind(), ErrorKind::PermissionDenied);
assert!(
refused.to_string().contains("foreign principal"),
"the refusal must name the foreign principal: {refused}"
);
}
#[test]
fn write_atomic_refuses_when_the_owner_seam_forces_a_foreign_principal() {
let scratch = Scratch::new("owner-foreign-write");
let artifact = scratch.path().join("artifact.json");
let ops = WindowsPrivateFile::new();
let refused = with_forced_foreign_owner(|| ops.write_atomic(&artifact, b"secret").unwrap_err());
assert_eq!(refused.kind(), ErrorKind::PermissionDenied);
assert!(
refused.to_string().contains("foreign principal"),
"the refused write must name the foreign principal: {refused}"
);
assert!(
!artifact.exists(),
"no artifact may land when ownership validation refuses the write"
);
}
#[test]
fn read_private_bounded_refuses_when_the_owner_seam_forces_a_foreign_principal() {
let scratch = Scratch::new("owner-foreign-read");
let artifact = scratch.path().join("artifact.json");
let ops = WindowsPrivateFile::new();
ops.write_atomic(&artifact, b"payload").unwrap();
let refused =
with_forced_foreign_owner(|| ops.read_private_bounded(&artifact, 64).unwrap_err());
assert_eq!(refused.kind(), ErrorKind::PermissionDenied);
assert!(
refused.to_string().contains("foreign principal"),
"the refused read must name the foreign principal: {refused}"
);
}
#[test]
fn copied_from_valid_rejects_a_null_and_an_oversized_sid_with_invalid_data() {
let null_kind = SidBuffer::copied_from_valid(std::ptr::null_mut())
.err()
.map(|error| error.kind());
assert_eq!(null_kind, Some(ErrorKind::InvalidData));
let mut oversized = [0_u8; 16];
oversized[0] = 1;
oversized[1] = 200;
let oversized_kind = SidBuffer::copied_from_valid(oversized.as_mut_ptr().cast())
.err()
.map(|error| error.kind());
assert_eq!(oversized_kind, Some(ErrorKind::InvalidData));
}
#[test]
fn a_profile_artifact_keeps_system_admins_and_user_and_never_users_across_create_and_replace() {
let local_app_data =
std::env::var_os("LOCALAPPDATA").expect("LOCALAPPDATA must exist on Windows");
let fresh = Scratch::adopt(Path::new(&local_app_data).join("Temp").join(format!(
".agent-desktop-acl-pin-{}-{:016x}",
std::process::id(),
scratch_nonce()
)));
let artifact = fresh.path().join("artifact.json");
let ops = WindowsPrivateFile::new();
ops.write_atomic(&artifact, b"{}")
.expect("the pinned create-path write must succeed under the user profile");
let created = inherited_ace_entries(&artifact);
assert_every_ace_is_inherited(&created);
assert_profile_security_principals(&created);
assert!(
artifact.exists(),
"the create-path write must leave a destination for the replace path to overwrite"
);
ops.write_atomic(&artifact, b"{\"v\":2}")
.expect("the pinned replace-path write must succeed over the pre-existing leaf");
assert_profile_security_principals(&inherited_ace_entries(&artifact));
}
fn assert_every_ace_is_inherited(entries: &[(String, bool)]) {
assert!(!entries.is_empty(), "the artifact must report ACL entries");
for (sid, inherited) in entries {
assert!(
*inherited,
"every entry on a freshly created profile leaf must be inherited; {sid} is explicit"
);
}
}
fn assert_profile_security_principals(entries: &[(String, bool)]) {
assert!(!entries.is_empty(), "the artifact must report ACL entries");
let sids: Vec<&str> = entries.iter().map(|(sid, _)| sid.as_str()).collect();
let system_class = well_known_sid_string(WinLocalSystemSid);
let administrators_class = well_known_sid_string(WinBuiltinAdministratorsSid);
let users_class = well_known_sid_string(WinBuiltinUsersSid);
let token_user = sid_string(&process_token_user_sid_for_tests().unwrap());
let token_owner = sid_string(process_token_owner_sid().unwrap());
assert!(
sids.contains(&system_class.as_str()),
"a SYSTEM-class principal must be present"
);
assert!(
sids.contains(&administrators_class.as_str()),
"an Administrators-class principal must be present"
);
assert!(
sids.contains(&token_user.as_str()) || sids.contains(&token_owner.as_str()),
"the current-user principal must be present"
);
assert!(
!sids.contains(&users_class.as_str()),
"no Users-class principal may appear on a profile leaf"
);
}
fn inherited_ace_entries(path: &Path) -> Vec<(String, bool)> {
let script = format!(
"$rules = [System.IO.FileInfo]::new('{}').GetAccessControl('Access')\
.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]); \
foreach ($rule in $rules) {{ \
[Console]::WriteLine(($rule.IdentityReference.Value + '|' + $rule.IsInherited)) }}",
path.display()
);
let output = std::process::Command::new("powershell.exe")
.args(["-NoProfile", "-NonInteractive", "-Command"])
.arg(&script)
.output()
.expect("powershell must be spawnable");
assert!(
output.status.success(),
"the module-free acl read must succeed: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| {
let (sid, inherited) = line
.split_once('|')
.expect("each line must be sid|inherited");
(sid.to_string(), inherited.eq_ignore_ascii_case("true"))
})
.collect()
}
fn sid_string(sid: &SidBuffer) -> String {
let mut text: windows_sys::core::PWSTR = std::ptr::null_mut();
let converted = unsafe { ConvertSidToStringSidW(sid.as_psid(), &mut text) };
assert!(converted != 0, "the SID must convert to its string form");
let mut length = 0_usize;
while unsafe { *text.add(length) } != 0 {
length += 1;
}
let value = String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(text, length) });
unsafe { LocalFree(text.cast()) };
value
}
fn well_known_sid_buffer(kind: WELL_KNOWN_SID_TYPE) -> SidBuffer {
let mut storage = [0_u64; 9];
let mut size: u32 = SECURITY_MAX_SID_SIZE;
let created = unsafe {
CreateWellKnownSid(
kind,
std::ptr::null_mut(),
storage.as_mut_ptr().cast(),
&mut size,
)
};
assert!(
created != 0,
"CreateWellKnownSid must succeed for kind {kind}"
);
SidBuffer::copied_from_valid(storage.as_mut_ptr().cast())
.expect("a well-known SID must be valid")
}
fn well_known_sid_string(kind: WELL_KNOWN_SID_TYPE) -> String {
sid_string(&well_known_sid_buffer(kind))
}

View file

@ -0,0 +1,152 @@
//! Per-component reparse-point rejection for private-file paths.
//!
//! A junction is creatable by an unprivileged user without
//! `SeCreateSymbolicLinkPrivilege`, and one planted on a private path
//! redirects where the product writes — no ACL on the intended destination
//! prevents a write that never reaches it. Every component of a write path is
//! therefore opened with `FILE_FLAG_OPEN_REPARSE_POINT` (never following the
//! link) and refused if it carries `FILE_ATTRIBUTE_REPARSE_POINT`. This is
//! the Windows analogue of the unix per-component symlink rejection in core's
//! `private_file_parent.rs`, and it restores the check the deleted v0.5.0
//! layer carried. Reads guard only the leaf, exactly as the unix baseline's
//! `O_NOFOLLOW` does.
use std::fs::{File, OpenOptions};
use std::io::ErrorKind;
use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
use std::path::{Component, Path, PathBuf};
use windows_sys::Win32::Storage::FileSystem::{
FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_BACKUP_SEMANTICS,
FILE_FLAG_OPEN_REPARSE_POINT, FILE_READ_ATTRIBUTES, READ_CONTROL,
};
use super::owner;
use super::{invalid_input, permission_denied};
const NO_FOLLOW_DIRECTORY_FLAGS: u32 = FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS;
#[derive(Clone, Copy, PartialEq, Eq)]
enum MissingComponents {
Create,
Reject,
}
pub(super) fn ensure_private_directory_chain(path: &Path) -> std::io::Result<()> {
walk_directory_components(path, MissingComponents::Create)?;
let directory = open_directory_no_follow(path, FILE_READ_ATTRIBUTES | READ_CONTROL)?;
require_verified_directory(&directory)?;
owner::require_owned_by_token_owner(&directory, "private file parent")
}
pub(super) fn require_reparse_free_directory_chain(path: &Path) -> std::io::Result<()> {
walk_directory_components(path, MissingComponents::Reject)
}
fn walk_directory_components(path: &Path, missing: MissingComponents) -> std::io::Result<()> {
let mut current = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => continue,
Component::ParentDir => {
return Err(invalid_input(
"private file parent must not contain parent traversal",
));
}
Component::Prefix(prefix) => {
current.push(prefix.as_os_str());
continue;
}
Component::RootDir | Component::Normal(_) => current.push(component.as_os_str()),
}
verify_directory_component(&current, missing)?;
}
Ok(())
}
fn verify_directory_component(
component_path: &Path,
missing: MissingComponents,
) -> std::io::Result<()> {
match open_directory_no_follow(component_path, FILE_READ_ATTRIBUTES) {
Ok(directory) => require_verified_directory(&directory),
Err(error)
if error.kind() == ErrorKind::NotFound && missing == MissingComponents::Create =>
{
create_directory_component(component_path)?;
let directory = open_directory_no_follow(component_path, FILE_READ_ATTRIBUTES)?;
require_verified_directory(&directory)
}
Err(error) => Err(error),
}
}
fn create_directory_component(component_path: &Path) -> std::io::Result<()> {
match std::fs::create_dir(component_path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == ErrorKind::AlreadyExists => Ok(()),
Err(error) => Err(error),
}
}
fn open_directory_no_follow(path: &Path, access: u32) -> std::io::Result<File> {
OpenOptions::new()
.access_mode(access)
.custom_flags(NO_FOLLOW_DIRECTORY_FLAGS)
.open(path)
}
fn require_verified_directory(directory: &File) -> std::io::Result<()> {
let attributes = handle_attributes(directory)?;
require_not_reparse_point(attributes, "private file path component is a reparse point")?;
if attributes & FILE_ATTRIBUTE_DIRECTORY == 0 {
return Err(permission_denied(
"private file path component must be a directory",
));
}
Ok(())
}
pub(super) fn open_leaf_regular_no_follow(
path: &Path,
options: &mut OpenOptions,
what: &str,
) -> std::io::Result<File> {
options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
let file = options.open(path)?;
require_regular_leaf(&file, what)?;
Ok(file)
}
pub(super) fn open_leaf_for_validation(path: &Path, what: &str) -> std::io::Result<File> {
let file = OpenOptions::new()
.access_mode(FILE_READ_ATTRIBUTES | READ_CONTROL)
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
.open(path)?;
require_regular_leaf(&file, what)?;
Ok(file)
}
fn require_regular_leaf(file: &File, what: &str) -> std::io::Result<()> {
let attributes = handle_attributes(file)?;
require_not_reparse_point(attributes, format!("{what} is a reparse point"))?;
if attributes & FILE_ATTRIBUTE_DIRECTORY != 0 {
return Err(permission_denied(format!("{what} is not a regular file")));
}
Ok(())
}
pub(super) fn require_verified_lease_directory(directory: &File) -> std::io::Result<()> {
require_verified_directory(directory)
}
fn require_not_reparse_point(attributes: u32, message: impl Into<String>) -> std::io::Result<()> {
if attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
return Err(permission_denied(message));
}
Ok(())
}
fn handle_attributes(file: &File) -> std::io::Result<u32> {
Ok(file.metadata()?.file_attributes())
}

View file

@ -0,0 +1,218 @@
use super::{Scratch, create_junction};
use crate::system::private_file::WindowsPrivateFile;
use agent_desktop_core::PrivateFileOps;
use std::io::{ErrorKind, Read, Write};
use std::path::Path;
#[test]
fn a_junction_component_on_the_write_path_is_refused_and_nothing_lands_at_its_target() {
let root = Scratch::new("junction-mid");
let elsewhere = Scratch::new("junction-elsewhere");
let junction = root.path().join("redirect");
create_junction(&junction, elsewhere.path());
let destination = junction.join("nested").join("artifact.json");
let outcome = WindowsPrivateFile::new().write_atomic(&destination, b"private bytes");
let landed = elsewhere.path().join("nested").join("artifact.json");
assert!(
outcome.is_err(),
"a write through a junction component must be refused"
);
assert!(
!landed.exists(),
"no artifact may land at the junction target"
);
assert!(
!elsewhere.path().join("nested").exists(),
"no directory may be created at the junction target"
);
}
#[test]
fn a_junction_as_the_immediate_parent_is_refused_and_nothing_lands_at_its_target() {
let root = Scratch::new("junction-parent");
let elsewhere = Scratch::new("junction-parent-elsewhere");
let junction = root.path().join("redirect");
create_junction(&junction, elsewhere.path());
let destination = junction.join("artifact.json");
let outcome = WindowsPrivateFile::new().write_atomic(&destination, b"private bytes");
assert!(
outcome.is_err(),
"a write whose parent is a junction must be refused"
);
assert!(
!elsewhere.path().join("artifact.json").exists(),
"no artifact may land at the junction target"
);
}
#[test]
fn a_junction_leaf_is_refused_for_private_opens() {
let root = Scratch::new("junction-leaf");
let elsewhere = Scratch::new("junction-leaf-elsewhere");
let junction = root.path().join("redirect");
create_junction(&junction, elsewhere.path());
let ops = WindowsPrivateFile::new();
assert!(ops.read_private_bounded(&junction, 1024).is_err());
assert!(ops.open_private_append(&junction).is_err());
assert!(ops.open_private_lock(&junction, false).is_err());
}
#[test]
fn a_regular_file_where_a_directory_is_expected_is_refused() {
let root = Scratch::new("file-component");
let blocking_file = root.path().join("blocking.txt");
std::fs::write(&blocking_file, b"a file, not a directory").unwrap();
let destination = blocking_file.join("artifact.json");
let outcome = WindowsPrivateFile::new().write_atomic(&destination, b"private bytes");
assert!(
outcome.is_err(),
"a path component that is a regular file must be refused"
);
assert_eq!(
std::fs::read(&blocking_file).unwrap(),
b"a file, not a directory",
"the blocking file must be left untouched"
);
}
#[test]
fn a_directory_destination_is_refused_for_atomic_writes() {
let root = Scratch::new("dir-destination");
let destination = root.path().join("already-a-directory");
std::fs::create_dir(&destination).unwrap();
let outcome = WindowsPrivateFile::new().write_atomic(&destination, b"private bytes");
assert!(outcome.is_err());
assert!(destination.is_dir(), "the directory must be left untouched");
}
#[test]
fn ensure_private_creates_a_nested_chain_that_accepts_writes() {
let root = Scratch::new("ensure-nested");
let nested = root.path().join("sessions").join("s1").join("trace");
let ops = WindowsPrivateFile::new();
ops.ensure_private(&nested).unwrap();
assert!(nested.is_dir());
let artifact = nested.join("segment.jsonl");
ops.write_atomic(&artifact, b"{\"event\":1}").unwrap();
assert_eq!(
ops.read_private_bounded(&artifact, 1024).unwrap(),
b"{\"event\":1}"
);
}
#[test]
fn write_read_roundtrip_preserves_bytes_and_enforces_read_limits() {
let root = Scratch::new("roundtrip");
let artifact = root.path().join("refmap.json");
let ops = WindowsPrivateFile::new();
ops.write_atomic(&artifact, b"twelve bytes").unwrap();
assert_eq!(
ops.read_private_bounded(&artifact, 12).unwrap(),
b"twelve bytes"
);
let over_limit = ops.read_private_bounded(&artifact, 11).unwrap_err();
assert_eq!(over_limit.kind(), ErrorKind::InvalidData);
let missing = ops
.read_private_bounded(&root.path().join("absent.json"), 64)
.unwrap_err();
assert_eq!(missing.kind(), ErrorKind::NotFound);
}
#[test]
fn overwriting_an_existing_destination_replaces_its_content() {
let root = Scratch::new("overwrite");
let artifact = root.path().join("latest.json");
let ops = WindowsPrivateFile::new();
ops.write_atomic(&artifact, b"first").unwrap();
ops.write_atomic(&artifact, b"second").unwrap();
assert_eq!(ops.read_private_bounded(&artifact, 64).unwrap(), b"second");
}
#[test]
fn append_opens_create_then_grow_the_file_with_a_readable_handle() {
let root = Scratch::new("append");
let segment = root.path().join("trace.jsonl");
let ops = WindowsPrivateFile::new();
let mut first = ops.open_private_append(&segment).unwrap();
first.write_all(b"one\n").unwrap();
drop(first);
let mut second = ops.open_private_append(&segment).unwrap();
second.write_all(b"two\n").unwrap();
let mut readable_probe = String::new();
(&second)
.read_to_string(&mut readable_probe)
.expect("the append handle must grant read access so it stays lockable");
drop(second);
assert_eq!(
ops.read_private_bounded(&segment, 64).unwrap(),
b"one\ntwo\n"
);
}
#[test]
fn lock_opens_honor_the_create_flag() {
let root = Scratch::new("lock");
let lock_path = root.path().join("cli.lock");
let ops = WindowsPrivateFile::new();
let missing = ops.open_private_lock(&lock_path, false).unwrap_err();
assert_eq!(missing.kind(), ErrorKind::NotFound);
drop(ops.open_private_lock(&lock_path, true).unwrap());
assert!(lock_path.is_file());
drop(ops.open_private_lock(&lock_path, false).unwrap());
}
#[test]
fn parent_traversal_components_are_rejected() {
let root = Scratch::new("traversal");
let sneaky = root.path().join("..").join("outside").join("artifact.json");
let outcome = WindowsPrivateFile::new().write_atomic(&sneaky, b"private bytes");
let refused = outcome.unwrap_err();
assert_eq!(refused.kind(), ErrorKind::InvalidData);
}
struct RestoreCurrentDirOnDrop(std::path::PathBuf);
impl Drop for RestoreCurrentDirOnDrop {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.0);
}
}
#[test]
fn a_leading_current_dir_component_is_skipped_and_the_write_lands_in_the_working_directory() {
let root = Scratch::new("curdir");
let original_working_dir =
std::env::current_dir().expect("the current working directory must be readable");
let _restore = RestoreCurrentDirOnDrop(original_working_dir);
std::env::set_current_dir(root.path())
.expect("the scratch root must be enterable as the working directory");
let relative = Path::new(".").join("sub").join("artifact.json");
let ops = WindowsPrivateFile::new();
ops.write_atomic(&relative, b"payload")
.expect("a leading current-dir component must be skipped, not rejected");
let landed = root.path().join("sub").join("artifact.json");
assert_eq!(ops.read_private_bounded(&landed, 64).unwrap(), b"payload");
}

View file

@ -0,0 +1,262 @@
//! Atomic promotion of a written temp file over its destination.
//!
//! `ReplaceFileW`, never `MoveFileEx`, replaces an existing destination. The
//! measured matrix (42/42 definite, zero successes without share-delete) is
//! asymmetric: `MoveFileEx` over an open target fails with
//! `ERROR_ACCESS_DENIED` (5) at every share mode including `0x4`/`0x7`, while
//! `ReplaceFileW` over a target held with `FILE_SHARE_DELETE` succeeds and
//! the held handle keeps reading the old bytes. On the source side the
//! tolerances invert: `ReplaceFileW` fails with `ERROR_SHARING_VIOLATION`
//! (32) over an open source at every share mode, so the fully written and
//! synced temp handle must be closed before the call. Error 5 is the
//! expected destination-side failure signature for move-style ops and 32 the
//! signature for replace-style ops. `ReplaceFileW` cannot create a missing
//! destination; an absent destination means no reader holds it, so that
//! branch falls back to the `MoveFileExW`-backed `std::fs::rename`.
//!
//! Temp files live in a write-scoped lease directory inside the
//! destination's parent, which inherits the same profile ACL. Each atomic
//! write creates its own lease directory — named with the pid plus a
//! per-write nonce, so concurrent same-parent writes in one process never
//! collide — and holds its handle for the duration of the write with a share
//! mode that deliberately omits `FILE_SHARE_DELETE`. That held handle is the
//! live-writer guard: a sweep before each write probes lease directories
//! with `DELETE` access and reclaims only those no live writer holds. The
//! lease handle is dropped and the directory removed on every exit path, so
//! a long-lived process retains no directory handle that would defeat
//! same-process snapshot pruning. The narrowed share mode applies
//! exclusively to this internal lease handle — artifact opens keep Rust's
//! default wide `FILE_SHARE_READ|WRITE|DELETE` mask, because any hardened
//! open that narrows it re-introduces the measured sharing-failure cluster.
//! Temp names reuse core's hashed-nonce scheme so they stay unpredictable to
//! a same-privilege racer.
use std::ffi::OsStr;
use std::fs::{File, OpenOptions};
use std::hash::{BuildHasher, RandomState};
use std::io::ErrorKind;
use std::os::windows::ffi::OsStrExt;
use std::os::windows::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use windows_sys::Win32::Foundation::{
ERROR_ACCESS_DENIED, ERROR_FILE_NOT_FOUND, ERROR_SHARING_VIOLATION,
};
use windows_sys::Win32::Storage::FileSystem::{
DELETE, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE,
FILE_SHARE_READ, FILE_SHARE_WRITE, ReplaceFileW,
};
use super::{invalid_input, locality, owner, path};
const TEMP_LEASE_PREFIX: &str = ".agent-desktop-tmp-p";
const LEASE_CREATE_ATTEMPTS: usize = 32;
const TEMP_CREATE_ATTEMPTS: usize = 32;
const MEASURED_REPLACE_FLAGS: u32 = 0;
pub(super) struct TempDirLease {
directory: PathBuf,
liveness_handle: Option<File>,
}
impl TempDirLease {
pub(super) fn directory(&self) -> &Path {
&self.directory
}
}
impl Drop for TempDirLease {
fn drop(&mut self) {
drop(self.liveness_handle.take());
let _ = std::fs::remove_dir_all(&self.directory);
}
}
pub(super) fn acquire_write_lease(parent: &Path) -> std::io::Result<TempDirLease> {
sweep_stale_lease_directories(parent);
for _ in 0..LEASE_CREATE_ATTEMPTS {
let directory = parent.join(fresh_lease_name());
match std::fs::create_dir(&directory) {
Ok(()) => {}
Err(error) if error.kind() == ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
}
match open_verified_liveness_handle(&directory) {
Ok(handle) => {
return Ok(TempDirLease {
directory,
liveness_handle: Some(handle),
});
}
Err(error) if lease_open_collision_is_retryable(&error) => {
let _ = std::fs::remove_dir_all(&directory);
continue;
}
Err(error) => {
let _ = std::fs::remove_dir_all(&directory);
return Err(error);
}
}
}
Err(std::io::Error::new(
ErrorKind::AlreadyExists,
"could not allocate a private temp lease directory",
))
}
/// A concurrent same-parent writer racing the sweep can leave a just-created
/// lease directory swept, delete-pending, or held by the sweep's probe handle,
/// so the liveness open surfaces `NotFound`, `ERROR_ACCESS_DENIED` (5), or
/// `ERROR_SHARING_VIOLATION` (32). Those are transient collisions retried on a
/// fresh nonce. Keying the OS-error cases on `raw_os_error` keeps the
/// owner/locality/reparse refusals — `PermissionDenied` with no OS code —
/// fatal, never retried.
fn lease_open_collision_is_retryable(error: &std::io::Error) -> bool {
if error.kind() == ErrorKind::NotFound {
return true;
}
match error.raw_os_error() {
Some(code) => code == ERROR_SHARING_VIOLATION as i32 || code == ERROR_ACCESS_DENIED as i32,
None => false,
}
}
fn open_verified_liveness_handle(directory: &Path) -> std::io::Result<File> {
let handle = OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
.open(directory)?;
path::require_verified_lease_directory(&handle)?;
owner::require_owned_by_token_owner(&handle, "the private temp directory")?;
locality::require_local_for_private_write(&handle, "the private temp directory")?;
Ok(handle)
}
fn fresh_lease_name() -> String {
let nonce = RandomState::new().hash_one(std::time::SystemTime::now());
format!("{TEMP_LEASE_PREFIX}{}-{nonce:016x}", std::process::id())
}
fn sweep_stale_lease_directories(parent: &Path) {
let Ok(entries) = std::fs::read_dir(parent) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name_text) = name.to_str() else {
continue;
};
if !name_text.starts_with(TEMP_LEASE_PREFIX) {
continue;
}
let candidate = entry.path();
if stale_lease_is_reclaimable(&candidate) {
let _ = std::fs::remove_dir_all(&candidate);
let _ = std::fs::remove_file(&candidate);
}
}
}
fn stale_lease_is_reclaimable(candidate: &Path) -> bool {
OpenOptions::new()
.access_mode(DELETE)
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
.open(candidate)
.is_ok()
}
pub(super) fn create_private_temp_file(
lease: &TempDirLease,
destination_name: &str,
) -> std::io::Result<(PathBuf, File)> {
for _ in 0..TEMP_CREATE_ATTEMPTS {
let temporary = lease
.directory()
.join(agent_desktop_core::temporary_file_name(OsStr::new(
destination_name,
)));
match OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary)
{
Ok(file) => return Ok((temporary, file)),
Err(error) if error.kind() == ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
}
}
Err(std::io::Error::new(
ErrorKind::AlreadyExists,
"could not allocate a private temporary file",
))
}
pub(super) fn promote_temp_to_destination(
destination: &Path,
temporary: &Path,
) -> std::io::Result<()> {
match replace_file_call(destination, temporary) {
Ok(()) => Ok(()),
Err(error) if error.raw_os_error() == Some(ERROR_FILE_NOT_FOUND as i32) => {
std::fs::rename(temporary, destination)
}
Err(error) => Err(annotate_replace_failure(error)),
}
}
pub(super) fn replace_file_call(destination: &Path, temporary: &Path) -> std::io::Result<()> {
let destination_wide = to_wide_null(destination)?;
let temporary_wide = to_wide_null(temporary)?;
let succeeded = unsafe {
ReplaceFileW(
destination_wide.as_ptr(),
temporary_wide.as_ptr(),
std::ptr::null(),
MEASURED_REPLACE_FLAGS,
std::ptr::null(),
std::ptr::null(),
)
};
if succeeded != 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
fn annotate_replace_failure(error: std::io::Error) -> std::io::Error {
let raw_code = error.raw_os_error().unwrap_or_default() as u32;
std::io::Error::new(
error.kind(),
format!(
"atomic replace failed: {}: {error}",
replace_style_failure_detail(raw_code)
),
)
}
pub(super) fn replace_style_failure_detail(code: u32) -> &'static str {
match code {
ERROR_SHARING_VIOLATION => {
"the destination is held open without FILE_SHARE_DELETE \
(32 is the destination-side signature for replace-style ops)"
}
ERROR_ACCESS_DENIED => {
"access was denied \
(5 is the destination-side signature for move-style ops, \
so from ReplaceFileW it is a genuine permission failure)"
}
_ => "the destination could not be replaced",
}
}
pub(super) fn to_wide_null(path: &Path) -> std::io::Result<Vec<u16>> {
let mut wide: Vec<u16> = path.as_os_str().encode_wide().collect();
if wide.contains(&0) {
return Err(invalid_input("private file path contains an interior NUL"));
}
wide.push(0);
Ok(wide)
}

View file

@ -0,0 +1,328 @@
use super::Scratch;
use crate::system::private_file::WindowsPrivateFile;
use crate::system::private_file::replace::{
replace_file_call, replace_style_failure_detail, to_wide_null,
};
use agent_desktop_core::PrivateFileOps;
use std::fs::{File, OpenOptions};
use std::io::Read;
use std::os::windows::fs::OpenOptionsExt;
use std::path::Path;
use windows_sys::Win32::Foundation::{ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION};
use windows_sys::Win32::Storage::FileSystem::{
FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, MOVEFILE_REPLACE_EXISTING, MoveFileExW,
};
const SHARE_NONE: u32 = 0;
const SHARE_ALL: u32 = FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE;
fn open_reader_with_share(path: &Path, share: u32) -> File {
OpenOptions::new()
.read(true)
.share_mode(share)
.open(path)
.expect("the holder handle must open")
}
fn move_file_call(source: &Path, destination: &Path) -> std::io::Result<()> {
let source_wide = to_wide_null(source)?;
let destination_wide = to_wide_null(destination)?;
let succeeded = unsafe {
MoveFileExW(
source_wide.as_ptr(),
destination_wide.as_ptr(),
MOVEFILE_REPLACE_EXISTING,
)
};
if succeeded != 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
fn seeded_pair(scratch: &Scratch) -> (std::path::PathBuf, std::path::PathBuf) {
let destination = scratch.path().join("destination.bin");
let replacement = scratch.path().join("replacement.bin");
std::fs::write(&destination, b"old bytes").unwrap();
std::fs::write(&replacement, b"new bytes").unwrap();
(destination, replacement)
}
#[test]
fn replace_succeeds_over_a_destination_held_with_share_delete_and_the_held_handle_reads_old_bytes()
{
let scratch = Scratch::new("replace-share-all");
let (destination, replacement) = seeded_pair(&scratch);
let held = open_reader_with_share(&destination, SHARE_ALL);
replace_file_call(&destination, &replacement)
.expect("ReplaceFileW must succeed over a share-delete holder");
let mut held_view = String::new();
(&held)
.read_to_string(&mut held_view)
.expect("the held handle must stay readable");
assert_eq!(
held_view, "old bytes",
"the held handle must still read the old bytes"
);
assert_eq!(
std::fs::read(&destination).unwrap(),
b"new bytes",
"a fresh open must observe the replacement bytes"
);
}
#[test]
fn replace_succeeds_over_a_destination_held_with_share_delete_only() {
let scratch = Scratch::new("replace-share-delete-only");
let (destination, replacement) = seeded_pair(&scratch);
let held = open_reader_with_share(&destination, FILE_SHARE_DELETE);
replace_file_call(&destination, &replacement)
.expect("ReplaceFileW must succeed over a delete-only-share holder");
let mut held_view = String::new();
(&held).read_to_string(&mut held_view).unwrap();
assert_eq!(held_view, "old bytes");
drop(held);
assert_eq!(std::fs::read(&destination).unwrap(), b"new bytes");
}
#[test]
fn replace_fails_32_over_a_destination_held_without_share_delete() {
for share in [
SHARE_NONE,
FILE_SHARE_READ,
FILE_SHARE_READ | FILE_SHARE_WRITE,
] {
let scratch = Scratch::new("replace-no-share-delete");
let (destination, replacement) = seeded_pair(&scratch);
let held = open_reader_with_share(&destination, share);
let refused = replace_file_call(&destination, &replacement).unwrap_err();
assert_eq!(
refused.raw_os_error(),
Some(ERROR_SHARING_VIOLATION as i32),
"share mode {share:#x} must fail with ERROR_SHARING_VIOLATION"
);
drop(held);
assert_eq!(
std::fs::read(&destination).unwrap(),
b"old bytes",
"the destination must be untouched after the refused replace"
);
}
}
#[test]
fn replace_fails_32_over_an_open_source_even_with_share_delete() {
for share in [FILE_SHARE_DELETE, SHARE_ALL] {
let scratch = Scratch::new("replace-open-source");
let (destination, replacement) = seeded_pair(&scratch);
let held = open_reader_with_share(&replacement, share);
let refused = replace_file_call(&destination, &replacement).unwrap_err();
assert_eq!(
refused.raw_os_error(),
Some(ERROR_SHARING_VIOLATION as i32),
"an open source must fail with ERROR_SHARING_VIOLATION even at share {share:#x}"
);
drop(held);
assert_eq!(std::fs::read(&destination).unwrap(), b"old bytes");
}
}
#[test]
fn move_file_ex_fails_5_not_32_over_an_open_target_even_at_full_share() {
let scratch = Scratch::new("move-open-target");
let (destination, replacement) = seeded_pair(&scratch);
let held = open_reader_with_share(&destination, SHARE_ALL);
let refused = move_file_call(&replacement, &destination).unwrap_err();
assert_ne!(
refused.raw_os_error(),
Some(ERROR_SHARING_VIOLATION as i32),
"move-style ops do not report the destination-side failure as a sharing violation"
);
assert_eq!(
refused.raw_os_error(),
Some(ERROR_ACCESS_DENIED as i32),
"MoveFileExW over an open target must fail with ERROR_ACCESS_DENIED"
);
drop(held);
assert_eq!(std::fs::read(&destination).unwrap(), b"old bytes");
}
#[test]
fn error_5_and_32_classify_to_opposite_sides_for_move_and_replace_style_ops() {
assert!(
replace_style_failure_detail(ERROR_SHARING_VIOLATION)
.contains("destination-side signature for replace-style")
);
assert!(replace_style_failure_detail(ERROR_ACCESS_DENIED).contains("move-style"));
}
#[test]
fn write_atomic_replaces_a_destination_held_open_by_a_wide_share_reader() {
let scratch = Scratch::new("write-over-reader");
let destination = scratch.path().join("refmap.json");
let ops = WindowsPrivateFile::new();
ops.write_atomic(&destination, b"{\"v\":1}").unwrap();
let held = open_reader_with_share(&destination, SHARE_ALL);
ops.write_atomic(&destination, b"{\"v\":2}").unwrap();
let mut held_view = String::new();
(&held).read_to_string(&mut held_view).unwrap();
assert_eq!(held_view, "{\"v\":1}");
assert_eq!(
ops.read_private_bounded(&destination, 64).unwrap(),
b"{\"v\":2}"
);
}
fn temp_lease_entries(parent: &Path) -> Vec<String> {
std::fs::read_dir(parent)
.unwrap()
.flatten()
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| name.starts_with(".agent-desktop-tmp-"))
.collect()
}
#[test]
fn a_write_reclaims_an_orphan_lease_directory_no_live_writer_holds() {
let scratch = Scratch::new("sweep-stale");
let stale = scratch.path().join(".agent-desktop-tmp-p4294967295");
std::fs::create_dir(&stale).unwrap();
std::fs::write(stale.join(".orphan.tmp"), b"leftover").unwrap();
let ops = WindowsPrivateFile::new();
ops.write_atomic(&scratch.path().join("artifact.json"), b"fresh")
.unwrap();
assert!(
!stale.exists(),
"a lease directory with no live holder must be reclaimed"
);
}
#[test]
fn a_lease_directory_held_without_share_delete_survives_a_concurrent_writes_sweep() {
let scratch = Scratch::new("sweep-live");
let foreign = scratch.path().join(".agent-desktop-tmp-p1");
std::fs::create_dir(&foreign).unwrap();
let liveness_handle = OpenOptions::new()
.read(true)
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
.custom_flags(windows_sys::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS)
.open(&foreign)
.unwrap();
let ops = WindowsPrivateFile::new();
ops.write_atomic(&scratch.path().join("artifact.json"), b"fresh")
.unwrap();
assert!(
foreign.is_dir(),
"a lease directory whose liveness handle is held must not be reclaimed"
);
drop(liveness_handle);
}
#[test]
fn a_successful_write_consumes_its_temporary_and_its_lease_directory() {
let scratch = Scratch::new("temp-confinement");
let ops = WindowsPrivateFile::new();
ops.write_atomic(&scratch.path().join("artifact.json"), b"payload")
.unwrap();
let siblings: Vec<String> = std::fs::read_dir(scratch.path())
.unwrap()
.flatten()
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(
siblings,
vec!["artifact.json"],
"temporaries must live inside the lease directory and both must be consumed"
);
}
#[test]
fn write_atomic_leaves_no_temp_lease_residue_on_success_or_failure() {
let scratch = Scratch::new("no-residue");
let destination = scratch.path().join("artifact.json");
let ops = WindowsPrivateFile::new();
ops.write_atomic(&destination, b"first").unwrap();
assert_eq!(
temp_lease_entries(scratch.path()),
Vec::<String>::new(),
"a successful write must leave no .agent-desktop-tmp-* residue"
);
let held = open_reader_with_share(&destination, FILE_SHARE_READ);
ops.write_atomic(&destination, b"second")
.expect_err("promotion over a no-share-delete holder must fail");
drop(held);
assert_eq!(
temp_lease_entries(scratch.path()),
Vec::<String>::new(),
"a failed write must leave no .agent-desktop-tmp-* residue"
);
assert_eq!(std::fs::read(&destination).unwrap(), b"first");
}
#[test]
fn two_concurrent_writers_to_the_same_parent_both_succeed_with_their_final_content() {
let scratch = Scratch::new("concurrent-writers");
let iterations = 64_usize;
let parent_a = scratch.path().to_path_buf();
let writer_a = std::thread::spawn(move || {
let ops = WindowsPrivateFile::new();
for iteration in 0..iterations {
let content = format!("a-{iteration}");
ops.write_atomic(&parent_a.join("a.json"), content.as_bytes())
.unwrap_or_else(|error| {
panic!("writer a iteration {iteration} must succeed: {error}")
});
}
});
let parent_b = scratch.path().to_path_buf();
let writer_b = std::thread::spawn(move || {
let ops = WindowsPrivateFile::new();
for iteration in 0..iterations {
let content = format!("b-{iteration}");
ops.write_atomic(&parent_b.join("b.json"), content.as_bytes())
.unwrap_or_else(|error| {
panic!("writer b iteration {iteration} must succeed: {error}")
});
}
});
writer_a.join().expect("writer a must not panic");
writer_b.join().expect("writer b must not panic");
let ops = WindowsPrivateFile::new();
assert_eq!(
ops.read_private_bounded(&scratch.path().join("a.json"), 64)
.unwrap(),
format!("a-{}", iterations - 1).into_bytes(),
"writer a's final content must survive the concurrent writes"
);
assert_eq!(
ops.read_private_bounded(&scratch.path().join("b.json"), 64)
.unwrap(),
format!("b-{}", iterations - 1).into_bytes(),
"writer b's final content must survive the concurrent writes"
);
}

View file

@ -0,0 +1,120 @@
use std::hash::{BuildHasher, RandomState};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
#[path = "locality_tests.rs"]
mod locality_tests;
#[path = "owner_tests.rs"]
mod owner_tests;
#[path = "path_tests.rs"]
mod path_tests;
#[path = "replace_tests.rs"]
mod replace_tests;
static SCRATCH_COUNTER: AtomicU64 = AtomicU64::new(0);
pub(super) struct Scratch {
root: PathBuf,
}
impl Scratch {
pub(super) fn new(name: &str) -> Self {
Self::adopt(std::env::temp_dir().join(format!(
"agent-desktop-pf-{name}-{}-{:016x}",
std::process::id(),
scratch_nonce()
)))
}
pub(super) fn adopt(root: PathBuf) -> Self {
std::fs::create_dir_all(&root).expect("scratch root must be creatable");
Self { root }
}
pub(super) fn path(&self) -> &Path {
&self.root
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
pub(super) fn scratch_nonce() -> u64 {
RandomState::new().hash_one((
std::process::id(),
SCRATCH_COUNTER.fetch_add(1, Ordering::Relaxed),
std::time::SystemTime::now(),
))
}
pub(super) fn create_junction(link: &Path, target: &Path) {
let status = std::process::Command::new("cmd")
.arg("/c")
.arg("mklink")
.arg("/J")
.arg(link)
.arg(target)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.expect("cmd /c mklink /J must spawn");
assert!(status.success(), "junction creation must succeed");
let created = std::fs::symlink_metadata(link).expect("junction metadata must be readable");
assert!(
created.file_type().is_symlink(),
"the planted link must surface as a reparse point"
);
}
#[test]
fn no_banned_acl_or_ace_symbol_appears_anywhere_in_this_module() {
let sources: &[(&str, &str)] = &[
("mod.rs", include_str!("mod.rs")),
("path.rs", include_str!("path.rs")),
("replace.rs", include_str!("replace.rs")),
("owner.rs", include_str!("owner.rs")),
("locality.rs", include_str!("locality.rs")),
("tests.rs", include_str!("tests.rs")),
("path_tests.rs", include_str!("path_tests.rs")),
("replace_tests.rs", include_str!("replace_tests.rs")),
("owner_tests.rs", include_str!("owner_tests.rs")),
("locality_tests.rs", include_str!("locality_tests.rs")),
];
let banned_symbols: Vec<String> = [
("Get", "Ace"),
("Get", "AclInformation"),
("Initialize", "Acl"),
("AddAccessAllowed", "AceEx"),
]
.iter()
.map(|(head, tail)| format!("{head}{tail}"))
.collect();
for (name, contents) in sources {
for symbol in &banned_symbols {
assert!(
!contents.contains(symbol.as_str()),
"{name} must not mention the banned ACL/ACE symbol {symbol}"
);
}
}
let directory = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/system/private_file");
let mut on_disk: Vec<String> = std::fs::read_dir(directory)
.expect("the module directory must be listable")
.flatten()
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.filter(|name| name.ends_with(".rs"))
.collect();
let mut scanned: Vec<String> = sources
.iter()
.map(|(name, _)| (*name).to_string())
.collect();
on_disk.sort();
scanned.sort();
assert_eq!(
on_disk, scanned,
"every source file in the module directory must be covered by this scan"
);
}

View file

@ -0,0 +1,294 @@
//! Session-scoped COM apartment lifetime for persistent Windows hosts.
//!
//! Nothing in the CLI binary or the FFI crate opens adapter sessions yet, so
//! this type is reachable only from `open_session` and its tests. It is not
//! dead code: `AdAdapter` in `crates/ffi/src/adapter.rs` is its natural future
//! home, with `ad_adapter_destroy` driving `close`.
use agent_desktop_core::{AdapterError, AdapterSession, Deadline, ErrorCode};
use crate::system::com_runtime::classify_mta_usage_hresult;
use crate::system::permissions::{com_hresult_detail, ensure_budget};
type MtaUsageRelease = Box<dyn FnOnce(usize) -> i32 + Send + Sync>;
struct AcquiredMtaUsage {
cookie_address: usize,
release: MtaUsageRelease,
}
/// Owns one session-scoped MTA usage registration, acquired as this session's
/// own `CoIncrementMTAUsage` cookie — separate from the process-lifetime
/// cookie the hosted-library bootstrap retains and never releases.
///
/// `CoIncrementMTAUsage` is the right keep-alive primitive for a session
/// because COM permits `CoDecrementMTAUsage` on its cookie from a different
/// thread than the one that acquired it. Releasing from `Drop` on whatever
/// thread runs it is therefore sound where `CoUninitialize` from the wrong
/// thread would not be, and holding the cookie as a plain address keeps the
/// type `Send` and `Sync` without any `unsafe impl`.
///
/// `close` and `Drop` both release by `Option::take` on the same field, so
/// the cookie is released exactly once whichever path runs first, and once
/// total when `close` is followed by the drop of its consumed box.
pub(crate) struct WindowsAdapterSession {
mta_usage: Option<AcquiredMtaUsage>,
}
pub(crate) fn open(deadline: Deadline) -> Result<WindowsAdapterSession, AdapterError> {
open_with(
deadline,
imp::co_increment_mta_usage,
Box::new(imp::co_decrement_mta_usage),
)
}
fn open_with(
deadline: Deadline,
acquire: impl FnOnce() -> (i32, usize),
release: MtaUsageRelease,
) -> Result<WindowsAdapterSession, AdapterError> {
ensure_budget(deadline)?;
let (hresult, cookie_address) = acquire();
classify_mta_usage_hresult(hresult).map_err(mta_usage_acquire_failure)?;
Ok(WindowsAdapterSession {
mta_usage: Some(AcquiredMtaUsage {
cookie_address,
release,
}),
})
}
impl WindowsAdapterSession {
fn release_mta_usage_once(&mut self) -> Option<i32> {
self.mta_usage.take().map(|usage| {
let AcquiredMtaUsage {
cookie_address,
release,
} = usage;
release(cookie_address)
})
}
}
impl AdapterSession for WindowsAdapterSession {
fn close(mut self: Box<Self>) -> Result<(), AdapterError> {
self.release_mta_usage_once().map_or(Ok(()), |hresult| {
classify_mta_usage_hresult(hresult).map_err(mta_usage_release_failure)
})
}
}
impl Drop for WindowsAdapterSession {
fn drop(&mut self) {
let _ = self.release_mta_usage_once();
}
}
fn mta_usage_acquire_failure(hresult: i32) -> AdapterError {
AdapterError::new(
ErrorCode::Internal,
"Session-scoped COM MTA usage could not be registered",
)
.with_platform_detail(com_hresult_detail(hresult))
.with_suggestion("Verify the host process allows COM initialization, then reopen the session")
}
fn mta_usage_release_failure(hresult: i32) -> AdapterError {
AdapterError::new(
ErrorCode::Internal,
"Session-scoped COM MTA usage could not be released",
)
.with_platform_detail(com_hresult_detail(hresult))
.with_suggestion(
"Treat the session as closed; the process retains one leaked MTA usage until exit",
)
}
#[cfg(target_os = "windows")]
mod imp {
use windows_sys::Win32::System::Com::{
CO_MTA_USAGE_COOKIE, CoDecrementMTAUsage, CoIncrementMTAUsage,
};
pub(super) fn co_increment_mta_usage() -> (i32, usize) {
let mut cookie: CO_MTA_USAGE_COOKIE = std::ptr::null_mut();
let hresult = unsafe { CoIncrementMTAUsage(&mut cookie) };
(hresult, cookie.expose_provenance())
}
pub(super) fn co_decrement_mta_usage(cookie_address: usize) -> i32 {
unsafe { CoDecrementMTAUsage(std::ptr::with_exposed_provenance_mut(cookie_address)) }
}
}
#[cfg(not(target_os = "windows"))]
mod imp {
const S_OK_HRESULT: i32 = 0;
pub(super) fn co_increment_mta_usage() -> (i32, usize) {
(S_OK_HRESULT, 0)
}
pub(super) fn co_decrement_mta_usage(_cookie_address: usize) -> i32 {
S_OK_HRESULT
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
const FAKE_COOKIE_ADDRESS: usize = 0x5EED;
const E_OUTOFMEMORY_HRESULT: i32 = 0x8007_000E_u32 as i32;
fn counting_release(count: &Arc<AtomicU32>) -> MtaUsageRelease {
let count = Arc::clone(count);
Box::new(move |_| {
count.fetch_add(1, Ordering::SeqCst);
0
})
}
fn counted_session(count: &Arc<AtomicU32>) -> WindowsAdapterSession {
open_with(
Deadline::after(1_000).unwrap(),
|| (0, FAKE_COOKIE_ADDRESS),
counting_release(count),
)
.unwrap()
}
#[test]
fn close_releases_the_acquired_cookie_exactly_once() {
let released: Arc<Mutex<Vec<usize>>> = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&released);
let session = open_with(
Deadline::after(1_000).unwrap(),
|| (0, FAKE_COOKIE_ADDRESS),
Box::new(move |cookie_address| {
sink.lock().unwrap().push(cookie_address);
0
}),
)
.unwrap();
Box::new(session).close().unwrap();
assert_eq!(*released.lock().unwrap(), vec![FAKE_COOKIE_ADDRESS]);
}
#[test]
fn a_session_dropped_without_close_releases_exactly_once() {
let count = Arc::new(AtomicU32::new(0));
let session = counted_session(&count);
drop(session);
assert_eq!(count.load(Ordering::SeqCst), 1);
}
#[test]
fn a_closed_session_releases_once_total_after_its_own_drop_also_ran() {
let count = Arc::new(AtomicU32::new(0));
let session = Box::new(counted_session(&count));
session.close().unwrap();
assert_eq!(count.load(Ordering::SeqCst), 1);
}
#[test]
fn a_failing_release_reports_the_hresult_and_never_retries() {
let count = Arc::new(AtomicU32::new(0));
let calls = Arc::clone(&count);
let session = open_with(
Deadline::after(1_000).unwrap(),
|| (0, FAKE_COOKIE_ADDRESS),
Box::new(move |_| {
calls.fetch_add(1, Ordering::SeqCst);
E_OUTOFMEMORY_HRESULT
}),
)
.unwrap();
let error = Box::new(session).close().unwrap_err();
assert_eq!(error.code, ErrorCode::Internal);
assert!(
error
.platform_detail
.is_some_and(|detail| detail.contains("0x8007000E"))
);
assert_eq!(count.load(Ordering::SeqCst), 1);
}
#[test]
fn a_failing_acquire_is_an_error_that_schedules_no_release() {
let count = Arc::new(AtomicU32::new(0));
let Err(error) = open_with(
Deadline::after(1_000).unwrap(),
|| (E_OUTOFMEMORY_HRESULT, 0),
counting_release(&count),
) else {
panic!("a failing acquire must not produce a session");
};
assert_eq!(error.code, ErrorCode::Internal);
assert!(
error
.platform_detail
.is_some_and(|detail| detail.contains("0x8007000E"))
);
assert_eq!(count.load(Ordering::SeqCst), 0);
}
#[test]
fn an_expired_deadline_times_out_before_touching_the_native_acquire() {
let count = Arc::new(AtomicU32::new(0));
let Err(error) = open_with(
Deadline::after(0).unwrap(),
|| panic!("an expired deadline must not reach the native acquire"),
counting_release(&count),
) else {
panic!("an expired deadline must not produce a session");
};
assert_eq!(error.code, ErrorCode::Timeout);
assert_eq!(count.load(Ordering::SeqCst), 0);
}
fn assert_send_sync<T: Send + Sync>() {}
#[test]
fn the_session_is_send_and_sync_as_a_value_and_as_a_box() {
assert_send_sync::<WindowsAdapterSession>();
assert_send_sync::<Box<WindowsAdapterSession>>();
}
#[test]
fn a_session_moved_to_another_thread_releases_exactly_once_there() {
let count = Arc::new(AtomicU32::new(0));
let session = Box::new(counted_session(&count));
std::thread::spawn(move || drop(session))
.join()
.expect("the thread that drops a moved session must not panic");
assert_eq!(count.load(Ordering::SeqCst), 1);
}
#[cfg(target_os = "windows")]
#[test]
fn a_real_com_mta_usage_acquires_and_closes_cleanly() {
let session = open(Deadline::after(5_000).unwrap()).unwrap();
Box::new(session)
.close()
.expect("releasing a real MTA usage cookie must succeed");
}
}

View file

@ -539,7 +539,7 @@ Exit codes: `0` success, `1` structured error (JSON on stdout), `2` argument/par
- SnapshotEngine filtering
- Error serialization
- JSON contract / output conformance coverage
- MockAdapter: in-memory PlatformAdapter returning hardcoded trees
- `NoopAdapter` (`tests/support/noop_ops.rs`) and per-test ad-hoc doubles: in-memory `PlatformAdapter` implementations returning hardcoded trees
**Unit tests (macos):**
- Role mapping coverage
@ -578,7 +578,7 @@ Current `.github/workflows/ci.yml` runs on push to main/master, pull_request, an
| `fmt` | ubuntu-latest | `cargo fmt --all -- --check`; shellcheck + bash3-compat on e2e scripts; `py_compile` + unittest on `tests/e2e/*.py`; actionlint on workflow files |
| `msrv` | ubuntu-latest | `cargo +1.89.0 check` (pinned MSRV) on core, linux, and the binary crate |
| `platform-check` | matrix: Linux / Windows / macOS | `cargo check --all-targets` per platform crate + binary — proves every crate compiles on its target |
| `test-windows` | windows-latest | `cargo test -p agent-desktop-core -p agent-desktop-windows --lib` — added in v0.6.0; the first lane that ever executed core's `#[cfg(windows)]` code. Phase 2.1 extends it to clippy, binary-crate tests, and the size check |
| `test-windows` | windows-latest | `cargo test -p agent-desktop-core -p agent-desktop-windows --lib` — added in v0.6.0; the first lane that ever executed core's `#[cfg(windows)]` code. Phase 2.1 extends it to clippy, binary-crate tests, the core-isolation check, and the size check |
| `test-linux` | ubuntu-latest | `cargo test -p agent-desktop-core -p agent-desktop-linux --lib` — added in v0.6.0; Phase 3.1 extends it the same way |
| `test` | macos-latest | Dependency isolation check (`cargo tree -p agent-desktop-core` has zero platform crate names), release-consistency check, file-size rule check, `cargo clippy --all-targets -- -D warnings`, core+macos unit tests, `locator_benchmark` example, `permission-contract.sh`, binary command tests, FFI integration tests, release binary build + version-flag check + 15MB size check, FFI cdylib build (`release-ffi` profile), FFI helper-discovery smoke, npm package tests + wrapper smoke |
| `ffi-python-smoke` | macos-latest | Builds the FFI dylib with the `stub-adapter` feature, runs `tests/ffi-python/smoke.py` against it |
@ -824,9 +824,9 @@ Every sub-phase below follows the same rendering shape: **Goal** (one or two sen
### Windows Engineering Invariants (from the Phase 2 plan, Unit 3)
1. `SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)` at startup.
1. `SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)` at startup. Microsoft recommends setting process-default DPI awareness by application manifest rather than by API call; the API call is a deliberate divergence, justified because a cdylib has no manifest of its own. A second call fails `ERROR_ACCESS_DENIED`, which means the host already decided and is tolerated, never fatal. V2 is never asserted by reading awareness back: the V2 call succeeds on the 1809 floor but `GetProcessDpiAwareness` has no V2 enumerant and reports the V1 string `PROCESS_PER_MONITOR_DPI_AWARE`.
2. `CoInitializeEx(NULL, COINIT_MULTITHREADED)` on main thread and on every dedicated UIA worker thread (UIA prefers MTA).
3. Never cache `IUIAutomationElement` across apartments. Event handlers are created, registered, removed, and drained on the same dedicated MTA thread; worker code re-resolves from `RefEntry` instead of moving elements across apartments.
3. Never cache `IUIAutomationElement` across apartments. Event handlers are created, registered, and removed on one dedicated MTA thread; they are not drained on it. Delivery is multi-threaded — callbacks arrive on several UIA-owned threads concurrently, the registering worker among them and the main thread never — so handler state must be safe for concurrent delivery rather than merely for one worker. Worker code re-resolves from `RefEntry` instead of moving elements across apartments.
4. UIA-first, SendInput-fallback (UIA patterns are focus-independent; `SendInput` is focus-dependent + UIPI-blocked for elevated targets).
5. `PostMessage WM_KEYDOWN` is DEAD for Chromium/UWP/games — not a viable alternative.
6. UIPI elevation detection via `GetTokenInformation(TokenIntegrityLevel)`. Ship `uiAccess=true` as optional signed release, not default.
@ -971,18 +971,17 @@ Every sub-phase 2.02.15 below is held to the same definition of done, stated
**Goal:** Stand up the Windows build/CI/session substrate so every later sub-phase lands on green CI and a constructible (if functionally empty) `WindowsAdapter`.
**Scope:**
- Extend the existing `test-windows` lane (shipped v0.6.0, runs core + `agent-desktop-windows` lib tests) to the full adapter surface: clippy `-D warnings` over `agent-desktop-core`/`agent-desktop-windows`/`agent-desktop`/`agent-desktop-ffi`, binary-crate tests (`cargo test -p agent-desktop` — `--lib` alone skips it, that crate has no lib target), core-isolation check, and a Windows-native release-binary size check
- Self-hosted interactive Windows runner registration for later UIA/shell integration tests, with RDP/session-isolation documented (an interactive session is required for UIA to see a real desktop; `tscon` is the documented console-reattach workaround — see Risk Register)
- `CoInitializeEx(NULL, COINIT_MULTITHREADED)` + `SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)` bootstrap at process start
- Extend the existing `test-windows` lane (shipped v0.6.0, runs core + `agent-desktop-windows` lib tests) to the full adapter surface: clippy `-D warnings` over `agent-desktop-core`/`agent-desktop-windows`/`agent-desktop`/`agent-desktop-ffi`, binary-crate tests (`cargo test -p agent-desktop` — `--lib` alone skips it, that crate has no lib target), core-isolation check, and a Windows-native release-binary size check. The binary-crate arm has a source prerequisite: `src/tests/snapshot_test.rs` builds the binary path without `std::env::consts::EXE_SUFFIX`, so `cargo test -p agent-desktop` fails 3 of 128 on Windows today. That fix lands before the lane extension — `src/tests/cli_process.rs` already uses `env!("CARGO_BIN_EXE_agent-desktop")` — or the lane lands red
- COM apartment and DPI bootstrap at process start, with different primitives for the two consumers. The CLI owns its process and uses `CoInitializeEx(NULL, COINIT_MULTITHREADED)`. The cdylib cannot: `CoInitializeEx` fails `RPC_E_CHANGED_MODE` against any host thread already in an STA, and its balance is per-thread, so it can never be released from a `Drop` running on another thread. The library path uses `CoIncrementMTAUsage`, whose cookie is thread-agnostic and which creates the MTA without converting a host thread that already chose STA. `RPC_E_CHANGED_MODE` means "borrowed the host's apartment, do not uninitialise" and is tolerated, never reported as failure. MTA is a requirement and not a preference — UIA documents that STA can prevent a client from removing event handlers. `SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)` runs in the same bootstrap on Invariant 1's terms: the API call is a documented divergence from Microsoft's manifest recommendation, `ERROR_ACCESS_DENIED` on a second call means the host already decided and is tolerated, and V2 is never asserted by reading awareness back
- `WindowsAdapterSession` implementing `AdapterSession` via `open_session` — owns COM apartment state so later sub-phases don't reinvent COM lifecycle
- Record the dependency pins below (re-verified against crates.io + supply-chain policy on 2026-07-25 during sub-phase 2.0 — see New Dependencies) without adding them; `uiautomation`/`windows-capture` are first consumed in 2.2/2.10
- Implement Windows private-file hardening from scratch, behind `PlatformAdapter` or as a Windows-gated dependency of the `agent-desktop-windows` crate — never as unconditional `agent-desktop-core` surface (see `docs/solutions/best-practices/never-ship-platform-code-that-ci-cannot-execute.md`) — satisfying, with evidence from 2.0's probes: `ReplaceFile` — not `MoveFileEx` — for an atomic replace whose destination a validation handle holds open, plus `FILE_SHARE_DELETE` on every concurrently-open handle, which is necessary but never sufficient on its own: `MoveFileEx` issues `ReplaceIfExists` rather than POSIX-semantics rename and fails `ERROR_ACCESS_DENIED (5)` over an open target even with share-delete, while `ReplaceFile` honors share-delete on the destination and refuses an open handle on the source, so the two APIs have opposite tolerances on opposite sides and the failure to expect is error 5, not the `ERROR_SHARING_VIOLATION (32)` the source-side case returns; owner validation against `TokenOwner`, not `TokenUser`; locality inference from `GetFileInformationByHandleEx(FileRemoteProtocolInfo, class 13)` only behind a control call on a known-good info class, because the API signals "local" by failing with `ERROR_INVALID_PARAMETER (87)` instead of returning a local protocol value and an out-of-range info class returns that same 87, making the code ambiguous without the control; and an ancestor-vs-leaf validation contract decided deliberately, matching or explicitly diverging from the unix leaf-only rule, so the private-artifact-writing path is real before any Windows code writes a refmap or trace file
- Record the dependency pins below (re-verified against crates.io + supply-chain policy on 2026-07-25 during sub-phase 2.0 — see New Dependencies) without adding `uiautomation` or `windows-capture`, which are first consumed in 2.2/2.10. The Win32 bindings this sub-phase's own scope calls are added here, not deferred: `CoInitializeEx`/`CoIncrementMTAUsage`, `SetProcessDpiAwarenessContext`, and the ACL, `TokenOwner` and atomic-replace surface are implemented and unit-tested in 2.1
- Implement Windows private-file hardening from scratch. The seam is the sub-phase plan's decision and is not pre-empted here, but it is constrained on both sides and neither obvious option is reachable as stated: every private-artifact write site lives in `agent-desktop-core` (`refs_store.rs`, `session/mod.rs`, `trace.rs`, `trace_artifact_budget.rs`, `commands/clipboard_get.rs`) and none of them holds an adapter handle, so routing the hardening behind `PlatformAdapter` means threading one in first; and core may not depend on `agent-desktop-windows`, which dependency inversion forbids and `ci.yml`'s isolation check enforces. What is non-negotiable is the rule that killed the previous attempt: no platform code lands in core that no CI lane executes (see `docs/solutions/best-practices/never-ship-platform-code-that-ci-cannot-execute.md`). The hardening satisfies, with evidence from 2.0's probes: `ReplaceFile` — not `MoveFileEx` — for an atomic replace whose destination a validation handle holds open, plus `FILE_SHARE_DELETE` on every concurrently-open handle, which is necessary but never sufficient on its own: `MoveFileEx` issues `ReplaceIfExists` rather than POSIX-semantics rename and fails `ERROR_ACCESS_DENIED (5)` over an open target even with share-delete, while `ReplaceFile` honors share-delete on the destination and refuses an open handle on the source, so the two APIs have opposite tolerances on opposite sides and the failure to expect is error 5, not the `ERROR_SHARING_VIOLATION (32)` the source-side case returns; owner validation against `TokenOwner`, not `TokenUser`; locality inference from `GetFileInformationByHandleEx(FileRemoteProtocolInfo, class 13)` only behind a control call on a known-good info class, because the API signals "local" by failing with `ERROR_INVALID_PARAMETER (87)` instead of returning a local protocol value and an out-of-range info class returns that same 87, making the code ambiguous without the control; and an ancestor-vs-leaf validation contract decided deliberately, matching or explicitly diverging from the unix leaf-only rule, so the private-artifact-writing path is real before any Windows code writes a refmap or trace file
**Key APIs:** `CoInitializeEx`, `SetProcessDpiAwarenessContext`, Win32 ACL / `TokenOwner` validation, `FILE_SHARE_DELETE` (private-file hardening)
**Key APIs:** `CoInitializeEx`, `CoIncrementMTAUsage`, `SetProcessDpiAwarenessContext`, `ReplaceFileW`, `GetFileInformationByHandleEx(FileRemoteProtocolInfo)`, Win32 ACL / `TokenOwner` validation, `FILE_SHARE_DELETE` (private-file hardening)
**Depends on:** nothing (opening sub-phase)
**Exit criteria:** the Windows-relevant package set is green on Windows CI — `agent-desktop-macos` does not compile on Windows, so "workspace" invocations scope to `agent-desktop-core`, `agent-desktop-windows`, `agent-desktop`, and `agent-desktop-ffi`, exactly as the `test-windows` lane already scopes them; `WindowsAdapter` constructs and satisfies the trait; every command returns honest `PLATFORM_NOT_SUPPORTED` on Windows; the permission probe is unit-tested against mocked COM security state; private-file hardening is unit-tested on the `windows-latest` CI lane, not merely `cargo check`-clean.
**Exit criteria:** the Windows-relevant package set is green on Windows CI — `agent-desktop-macos` does not compile on Windows, so "workspace" invocations scope to `agent-desktop-core`, `agent-desktop-windows`, `agent-desktop`, and `agent-desktop-ffi`, exactly as the `test-windows` lane already scopes them; `WindowsAdapter` constructs and satisfies the trait; every adapter-backed command returns honest `PLATFORM_NOT_SUPPORTED` on Windows, while the commands that reach no adapter — `version`, `skills`, `session`, `trace`, `status`, `permissions` — succeed, `status` already reporting `platform: "windows"` with an empty `supported_surfaces`; the permission probe is unit-tested against mocked COM security state; private-file hardening is unit-tested on the `windows-latest` CI lane, not merely `cargo check`-clean, and asserts only what travels off the machine 2.0 measured — `windows-latest` now resolves to Server 2025 while every private-file observation was taken on Server 2019 build 17763, so the tests assert that a new file's owner equals `TokenOwner` and that validation resolves the nearest protected ancestor, never the specific SIDs or ancestor chain this one VM presented, and never the non-admin case 2.0 had no account to exercise.
**Est. PR size:** ~1.3k LOC (bootstrap ~0.8k + from-scratch private-file hardening ~0.5k)
@ -997,7 +996,7 @@ Every sub-phase 2.02.15 below is held to the same definition of done, stated
- `CacheRequest` batched attribute reads (the UIA analogue of `AXUIElementCopyMultipleAttributeValues`)
- Committed probe examples: raw UIA dumps of Notepad and Explorer, checked in as evidence alongside the sub-phase plan
**Key APIs:** `IUIAutomation.ElementFromHandle()`, `IUIAutomationTreeWalker.GetFirstChild`/`GetNextSibling`, `CacheRequest` (`uiautomation` crate 0.25+ wrapping the `windows` crate's COM bindings)
**Key APIs:** `IUIAutomation.ElementFromHandle()`, `IUIAutomationTreeWalker.GetFirstChild`/`GetNextSibling`, `CacheRequest` (`uiautomation` crate 0.25+ wrapping the `windows` crate's COM bindings; construct the client with `UIAutomation::new_direct()``UIAutomation::new()` initializes the COM library itself and would re-initialise the apartment 2.1 already established)
**Depends on:** 2.1
@ -1103,7 +1102,7 @@ Every sub-phase 2.02.15 below is held to the same definition of done, stated
| Capability | Technology | Details |
|------------|-----------|---------|
| Tree root | `IUIAutomation.ElementFromHandle()` | Via `uiautomation` crate (v0.25+) wrapping UIA COM APIs via `windows` crate |
| Tree root | `IUIAutomation.ElementFromHandle()` | Via `uiautomation` crate (v0.25+) wrapping UIA COM APIs via `windows` crate. Construct with `UIAutomation::new_direct()`, never `UIAutomation::new()` — the latter initializes the COM library itself and would re-initialise the apartment 2.1 established |
| Children | `IUIAutomationTreeWalker.GetFirstChild` / `GetNextSibling` | With `CacheRequest` for batch attribute retrieval. The speedup is a phase split, not one multiplier: measured on UIA3 COM over a 220-node Explorer window reading 8 properties, building the cache makes the find pass *slower* (180 ms vs 117 ms) and the property-read pass ~300x faster (372 ms vs 1.2 ms), netting ~2.7x for a single full-tree read. Cache only the properties that will be read, and expect the win from repeated reads over a cached tree rather than from the walk. On plain Win32 trees served by in-process client-side providers, unconditional caching is a net pessimization |
| Role mapping | `UIA ControlType` integers | Map to unified role enum in `tree/roles.rs` — e.g. `UIA_ButtonControlTypeId``button` |
| Click | `InvokePattern.Invoke()` | Pattern-based; coordinate click via SendInput only under explicit physical policy |
@ -1212,15 +1211,18 @@ Every sub-phase 2.02.15 below is held to the same definition of done, stated
- `AutomationId` set on every interactive target from day one (unlike macOS, which had to retrofit `AXIdentifier` — Windows gets this right from the start)
- Fixture targets mirroring `AgentDeskFixture.swift`: delayed-enable, zero-bounds, duplicate-title, occlusion, disclosure
- Harness port (bash via Git-Bash, or a PowerShell driver) asserting every effect by independent re-observation, never the command's own `ok:true` — same contract as `tests/e2e/run.sh`
- `windows-e2e` workflow_dispatch job on the self-hosted interactive Windows runner (registered in 2.1)
- Self-hosted interactive Windows runner registration — this is the first sub-phase whose gate needs a real desktop, so the runner is registered here rather than standing idle through the sub-phases that do not use it. A service-mode runner has no interactive desktop and cannot see UIA at all, so the runner launches from a Task Scheduler task triggered at log-on that runs `run.cmd` inside the interactive session
- Public-repo hardening on that registration. This repository is public, and GitHub's own guidance is that self-hosted runners should almost never be used for public repositories, because any user can open a pull request and compromise the environment: the runner's workflow is `workflow_dispatch`-triggered only and never `pull_request`, the fork-PR approval policy is written down, and ephemeral/JIT versus persistent registration is an explicit recorded decision rather than a default
- Registration is a measurement obligation as much as infrastructure: it creates the first non-console session this project can observe, and 2.12 closes 2.0's deferred RDP/session-isolation row by measuring it there rather than by documenting it (an interactive session is required for UIA to see a real desktop; `tscon` is the documented console-reattach workaround and leaves the machine unlocked — see Risk Register). Until that measurement lands, no Windows adapter behavior assumes console-session semantics and this document claims no RDP or remote-session support
- `windows-e2e` workflow_dispatch job on that runner
**Key APIs:** `csc.exe`, WinForms `AutomationProperties.AutomationId`
**Depends on:** 2.7, 2.8, 2.9, 2.10, 2.11 (everything the harness exercises)
**Exit criteria:** the full Windows live gate is green in both headless and headed tiers.
**Exit criteria:** the full Windows live gate is green in both headless and headed tiers on the self-hosted interactive runner; the runner's `workflow_dispatch`-only triggering, fork-PR approval policy and ephemeral-versus-persistent decision are written down alongside it; and 2.0's deferred RDP/session-isolation row is closed by measurement on that runner.
**Est. PR size:** ~2k LOC (mostly C#/scripts, not adapter Rust)
**Est. PR size:** ~2.3k LOC (mostly C#/scripts plus runner registration and its hardening policy, not adapter Rust)
### 2.13 — FFI, npm, Release
@ -1337,7 +1339,7 @@ agent-desktop-macos = { path = "crates/macos" }
# crates/windows/Cargo.toml
[target.'cfg(target_os = "windows")'.dependencies]
uiautomation = "0.25"
windows = { version = "0.62.2", features = ["Win32_UI_Input", "Win32_UI_Input_KeyboardAndMouse", "Win32_System_Com", "Win32_System_DataExchange", "Win32_UI_WindowsAndMessaging", "Win32_Graphics_Gdi", "Graphics_Capture", "Win32_Graphics_Direct3D11"] }
windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_UI_Input", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_HiDpi", "Win32_System_Com", "Win32_System_DataExchange", "Win32_UI_WindowsAndMessaging", "Win32_Storage_FileSystem", "Win32_Security", "Win32_Security_Authorization", "Win32_Graphics_Gdi", "Graphics_Capture", "Win32_Graphics_Direct3D11"] }
windows-capture = "2.0.0"
# crates/macos/Cargo.toml
@ -2479,12 +2481,12 @@ See [Command Surface Architecture](#command-surface-architecture-dry-invariant)
| Phase 1.5 | Same as Phase 1 on PRs; release workflow fans out to `macos-latest` × 2 darwin arches + `ubuntu-22.04` + `ubuntu-22.04-arm` + `windows-latest` for the FFI matrix |
| Phase 1.6 | `ci.yml`: `fmt` (ubuntu-latest), `msrv` (ubuntu-latest, Rust 1.89.0), `platform-check` (matrix Linux/Windows/macOS, `cargo check` only), `test` (macos-latest, full suite), `ffi-python-smoke`, `ffi-header-drift`, `ffi-panic-guard`, `ffi-passthrough` (ubuntu-latest). Outside `ci.yml`: `native-e2e.yml` (self-hosted macOS, workflow_dispatch), `codeql.yml`, `supply-chain.yml` |
| v0.6.0 (current) | Real `test-windows` (`windows-latest`) and `test-linux` (`ubuntu-latest`) lanes execute `cargo test -p agent-desktop-core -p agent-desktop-{windows,linux} --lib` on every PR, alongside the macOS `test` job — core's platform-conditional code is now executed, not merely type-checked, on all three OSes. `scripts/perf-baseline-compare.sh` remains a per-PR Definition-of-Done review step, not a blocking job |
| Phase 2 | The Windows test lane already exists as of v0.6.0; sub-phase 2.1 extends it to the adapter surface (clippy over `agent-desktop-windows`, binary-crate tests, size check) and registers the self-hosted interactive Windows runner whose UIA/shell integration lane lands at 2.12 |
| Phase 2 | The Windows test lane already exists as of v0.6.0; sub-phase 2.1 extends it to the adapter surface (clippy over `agent-desktop-windows`, binary-crate tests, size check); the self-hosted interactive Windows runner is registered at 2.12, the sub-phase whose UIA/shell integration lane needs it |
| Phase 3 | The Linux test lane already exists as of v0.6.0; sub-phase 3.1 extends it to the adapter surface; an interactive Ubuntu GNOME runner is added for AT-SPI2/shell integration tests at 3.12 |
| Phase 4 | macOS + Windows + Ubuntu (+ MCP protocol tests) |
| Phase 5 | macOS + Windows + Ubuntu (+ daemon tests, package build verification) |
All runners enforce: `cargo clippy --all-targets -- -D warnings`, the tests for the packages that build on that OS (`cargo test --workspace` on macOS; the Windows and Linux lanes scope to `agent-desktop-core` plus their own platform crate, since `agent-desktop-macos` does not compile off macOS — and `--lib` alone never covers the `agent-desktop` binary crate, which has no lib target), `cargo tree -p agent-desktop-core` contains zero platform crate names, binary size <15MB. Every Phase 2/3 sub-phase additionally runs `scripts/perf-baseline-compare.sh` on hot-path changes see the [Cross-cutting sub-phase DoD](#cross-cutting-sub-phase-dod).
Every runner runs the tests for the packages that build on that OS (`cargo test --workspace` on macOS; the Windows and Linux lanes scope to `agent-desktop-core` plus their own platform crate, since `agent-desktop-macos` does not compile off macOS — and `--lib` alone never covers the `agent-desktop` binary crate, which has no lib target). The other three gates are macOS-only today: `cargo clippy --all-targets -- -D warnings`, the `cargo tree -p agent-desktop-core` isolation check and the <15MB binary-size cap all run in the `test` job alone, while `test-windows` and `test-linux` each run a single `--lib` test invocation. Extending those three to the Windows and Linux lanes is sub-phase 2.1's and 3.1's work. Every Phase 2/3 sub-phase additionally runs `scripts/perf-baseline-compare.sh` on hot-path changes see the [Cross-cutting sub-phase DoD](#cross-cutting-sub-phase-dod).
### Dependency Introduction Schedule
@ -2548,6 +2550,6 @@ All Phase 2/3 pins above were recorded at 2026-04 research time; re-verify again
| R9 | Headless operation requirement | High | Critical | Phase 1 introduced `ActionRequest`/`InteractionPolicy`, default no focus steal/cursor movement, and explicit physical/headed policy paths; Phase 1.6 added default-on auto-wait and the occlusion gate on top. Phase 2/3 preserve the same contract for Windows/Linux. |
| R10 | Command registry link-GC | Medium | High | Research Topic B confirmed `inventory`/`linkme` are unreliable across linkers for cdylib consumers. Resolved by pure `build.rs` filesystem enumeration — zero linker magic, once that registry migration (P2-O16) lands. |
| R11 | Skeleton traversal cross-platform | Low | High | Core is already platform-agnostic (`crates/core/src/snapshot_ref.rs`); Windows needs ~50 LOC glue (`ControlViewWalker` + `FindAll(TreeScope_Children, TrueCondition)` + fresh `UICacheRequest` per drill-down). Research Topic 4 confirmed `ElementFromHandle(hwnd)` is headless-safe. |
| R12 | RDP / session-isolation blocks Windows dev and CI | Medium | High | UIA requires an interactive session — an RDP disconnect can drop the console session to a non-interactive state. Document the `tscon` console-reattach workaround for the self-hosted runner (sub-phase 2.1); mirrors the macOS exclusive-desktop gate (`AGENT_DESKTOP_E2E_EXCLUSIVE=1` + `interaction_lock.py`) that already serializes native e2e runs. |
| R12 | RDP / session-isolation blocks Windows dev and CI | Medium | High | UIA requires an interactive session — an RDP disconnect can drop the console session to a non-interactive state. Document the `tscon` console-reattach workaround for the self-hosted runner (sub-phase 2.12, where that runner is registered); mirrors the macOS exclusive-desktop gate (`AGENT_DESKTOP_E2E_EXCLUSIVE=1` + `interaction_lock.py`) that already serializes native e2e runs. |
| R13 | UIA event handler MTA lifecycle leaks | Medium | Medium | `RemoveAutomationEventHandler` races the final in-flight callback dispatch on the MTA worker thread if torn down naively. Use the post-remove-barrier pattern (`Arc<Handler>` outlives the final callback) documented in the Windows Engineering Invariants — apply it from the first sub-phase that registers a handler (`watch`, once P2-O11 lands), not retrofitted after a leak is observed. The barrier is not where the cost is: 2.0 measured removal under 296 in-flight events at 72 ms, but removal on an *idle* stream after window open/close churn while handlers were registered at 86 s — 63 ms without that churn, superlinear, and not avoided by making callbacks cheap. Bound and budget handler removal, and avoid holding handlers across window open/close churn. |
| R14 | Merge-train discipline: integration branch drifts from `main` | Medium | Medium | 16 sub-phases landing serially into `feat/windows-adapter` (then `feat/linux-adapter`) is a long-lived branch by construction. Mitigate with a rebase cadence (rebase onto `main` at the start of each sub-phase, not just before the final merge) and treat each sub-phase's own review as a checkpoint rather than deferring all review to the 2.15/3.15 hardening pass. |

View file

@ -0,0 +1,421 @@
---
title: Windows Toolchain, CI & COM Bootstrap (Sub-phase 2.1) - Plan
type: feat
date: 2026-07-27
origin: docs/phases.md
artifact_contract: ce-unified-plan/v1
artifact_readiness: implementation-ready
product_contract_source: docs/phases.md §Phase 2 sub-phase 2.1
execution: code
---
# Windows Toolchain, CI & COM Bootstrap (Sub-phase 2.1) - Plan
## Goal Capsule
- **Objective:** Stand up the Windows build, CI, and session substrate so every later sub-phase lands on green CI and a constructible `WindowsAdapter` — and rebuild the private-file layer that v0.5.0 shipped wrong, this time against measured evidence and behind a seam that actually exists.
- **Authority hierarchy:** `docs/phases.md` §2.1 (as corrected in `31ffd5f` and `4206c72`) > this plan > implementer judgment. The four session-settled decisions — **KTD1, KTD3, KTD4, and the deferral of self-hosted runner registration to 2.12** — are product law for this PR. KTD2 and KTD5KTD9 are evidence-driven and may be revised if implementation disproves them.
- **Stop conditions:** Do not register a self-hosted runner — that moved to 2.12. Do not add `uiautomation` or `windows-capture`; they are first consumed in 2.2/2.10. Do not implement UIA tree walking, element wrapping, or any observation capability — that is 2.2+. Do not put Win32 bindings or `#[cfg(windows)]` platform logic in `agent-desktop-core`. If evidence contradicts a settled decision above, stop and surface it.
- **Execution profile:** One PR into `feat/windows-adapter`, never `main`. Target ≈1.6k LOC. Conventional Commits.
- **Tail ownership:** The implementer opens the PR against `feat/windows-adapter` and reports the Verification Contract results.
---
## Product Contract
### Summary
Sub-phase 2.0 proved what Windows actually does. 2.1 is the first Rust sub-phase: it extends the Windows CI lane from a single `--lib` invocation to the full Windows package surface, establishes COM apartment and DPI state at process start with different primitives for the CLI and the cdylib, gives `WindowsAdapter` a session type that owns that apartment, adds a truthful permission probe, and rebuilds private-file hardening from scratch behind a new core seam.
### Problem Frame
`crates/windows` is a 63-line stub: a unit struct with four empty capability impls (`crates/windows/src/adapter.rs:3-20`). Everything later sub-phases need — a COM apartment, DPI awareness, a session lifetime, a truthful permission report, a safe private-artifact write path — does not exist.
Two of those are not greenfield. The private-file layer **already shipped once and was deleted**: 1,062 LOC of Win32 in `agent-desktop-core` that failed 225 of 940 tests on first contact with Windows and was removed in `8ad66b8` (PR #106). Its four failure clusters are documented, its worst defect is recovered verbatim, and sub-phase 2.0 measured the correct behaviour for every question it got wrong. And the CI lane that would have caught it (`ci.yml:223-261`) still runs only `cargo test -p agent-desktop-core -p agent-desktop-windows --lib` — no clippy, no binary-crate tests, no isolation check, no size gate.
### Requirements
- **R1.** The `test-windows` lane runs clippy `-D warnings` over `agent-desktop-core`, `agent-desktop-windows`, `agent-desktop`, and `agent-desktop-ffi`; the binary-crate tests (`cargo test -p agent-desktop`); the core-isolation check; and a Windows-native release-binary size check against the 15 MiB cap. Every step in the lane runs under an isolated `HOME`.
- **R2.** COM apartment and DPI awareness are established at process start for both consumers, at a call site the consumer cannot skip (KTD2, KTD6).
- **R3.** `WindowsAdapterSession` implements `AdapterSession` via `open_session` and owns COM apartment lifetime, so 2.2+ consume an apartment rather than creating one.
- **R4.** `WindowsAdapter` constructs and satisfies `PlatformAdapter`; every **adapter-backed** command returns honest `PLATFORM_NOT_SUPPORTED`; the commands that reach no adapter (`version`, `skills`, `session`, `trace`, `status`, `permissions`) continue to succeed.
- **R5.** A Windows permission probe reports truthfully and is unit-tested against mocked security state, on a host-independent seam.
- **R6.** Windows private-file hardening exists behind a core-defined seam implemented in `agent-desktop-windows` (KTD1), is **installed at a site no consumer can bypass**, and satisfies every measured behaviour in R7.
- **R7.** The hardening implements, against 2.0's evidence: `ReplaceFile` — not `MoveFileEx` — for an atomic replace whose destination a validation handle holds open; `FILE_SHARE_DELETE` on every concurrently-open handle, necessary but never sufficient; **error 5, not 32**, as the expected destination-side failure; owner validation against `TokenOwner`, not `TokenUser`; and locality inference from `FileRemoteProtocolInfo` (class 13) only behind a control call on a known-good class.
- **R8.** Every path component is rejected if it carries `FILE_ATTRIBUTE_REPARSE_POINT`, matching the per-component symlink rejection the unix path already performs. The hardening **does not** parse or validate DACLs and **does not** author security descriptors — it relies on the profile inheritance measured in A11-4 (KTD3).
- **R9.** The dependency pins for `uiautomation` and `windows-capture` are recorded but not added. Win32 bindings needed by 2.1's own scope **are** added, to the platform crate only (KTD4).
- **R10.** No Win32 binding crate and no unallowlisted `#[cfg(windows)]` platform logic enters `agent-desktop-core`. **Both halves are mechanically gated**, not merely asserted.
### Key Decisions
- **Private-file code lives behind a core-defined trait, implemented in the platform crate.** (session-settled: user-directed — chosen over `#[cfg(windows)]` inside core, which is the exact shape PR #106 deleted, and over deferring the hardening entirely.) Governs R6, R10.
- **Windows relies on profile inheritance instead of validating ACLs, and rejects reparse points per component.** (Supersedes the earlier session-settled "walk to the nearest protected ancestor" decision — reversed on measured evidence, see KTD3. A11-4 shows a leaf under the profile already inherits SYSTEM + Administrators + user and no `BUILTIN\Users`, so the walk would verify what Windows guarantees; and the only principal it would exclude, another administrator, holds `SeTakeOwnershipPrivilege` and can bypass any DACL.) Governs R7, R8.
- **Win32 bindings come from `windows-sys` in `crates/windows`, target-gated.** (session-settled: user-directed — chosen over hand-written `extern "system"` declarations, because the struct-layout transcription that hand-binding requires is the precise bug class that killed the previous layer.) Governs R9.
- **Self-hosted runner registration is not in 2.1.** (session-settled: user-directed — chosen over registering it here or standing up an ephemeral/JIT runner now: a security-sensitive persistent runner on a public repository should be registered by the sub-phase that needs an interactive desktop, not left idle from 2.1 through 2.11.) Moved to 2.12 along with ledger row A10-2's closure.
### Scope Boundaries
- **Out:** self-hosted runner registration, the RDP/session-transition measurement, and ledger row A10-2's closure — all now 2.12.
- **Out:** `uiautomation`, `windows-capture`, and any UIA tree, element, resolution, action, or input capability — 2.2+. This includes the native-handle downcast guard macOS has at `crates/macos/src/adapter.rs:15-31`: it exists to validate wrapped elements, and Windows has none yet.
- **Out:** repairing workspace `default-members` so bare `cargo build --release` works off-macOS. Recorded as ledger row R6-3 and left alone here: it changes what an unqualified cargo invocation does on every platform, which is wider than this sub-phase's mandate. See Open Questions — it has a working consequence for this PR, handled in the Verification Contract.
- **Out:** `.gitattributes`. The index is clean — `git ls-files --eol` reports 1158 files `i/lf` and zero CRLF — so it is a determinism measure, not a repair.
- **Deferred to follow-up:** correcting the `MockAdapter` fiction in `CLAUDE.md:382`, `AGENTS.md:395`, `CONTRIBUTING.md:58`, and the stale `FileRemoteProtocolInfo` claim in `docs/solutions/best-practices/never-ship-platform-code-that-ci-cannot-execute.md:92`. `docs/phases.md` is already correct on both.
---
## Planning Contract
### Key Technical Decisions
- **KTD1. The private-file seam is a core-defined trait implemented in the platform crate.** (session-settled: user-directed — chosen over `#[cfg(windows)]` in core.) Governs R6, R10. `docs/phases.md:978` previously prescribed "behind `PlatformAdapter` or as a Windows-gated dependency of `agent-desktop-windows`", and neither is reachable: every private-artifact write site is in core with no adapter handle, and core may not depend on the platform crate (`ci.yml:114-121`). Core therefore defines a narrow trait with a **default implementation equal to today's portable behaviour**, so unix and macOS are byte-for-byte unchanged and the risk is confined to the Windows arm.
- **KTD2. The seam and the apartment install where a consumer cannot skip them.** Governs R2, R6. **`ad_init` is not an install point.** Its own doc says so — `crates/ffi/src/abi_version.rs:34-36`: *"No global state is initialised by this call — skipping it does not prevent adapter functions from operating"* — and the committed header labels it **"(Optional)"** at `crates/ffi/include/agent_desktop.h:10`. Installing there would mean any host that skips it silently runs every private-artifact write through the portable default, with nothing observable distinguishing that state from the hardened one. The repo already solved this shape: `crates/ffi/src/adapter.rs:107-118` calls `ensure_cocoa_multithreaded()?` in the macOS arm of `build_adapter()` immediately before constructing the adapter, while the Windows arm is a bare `Ok(Box::new(WindowsAdapter::new()))`. Both the apartment bootstrap and the seam install go **there** for the cdylib, and at the top of `run()` for the CLI — before `resolve_active_session`, which reads private files at `src/main.rs:118`. `ad_init` stays a pure version check so the committed header and the `ffi-header-drift` job are untouched.
- **KTD3. Windows does not validate ACLs at all. It relies on profile inheritance and rejects reparse points.** Governs R7, R8. This **reverses** the earlier session-settled "walk to the nearest protected ancestor" decision, on evidence that arrived after it was made.
The asymmetry with unix is measurable, and it runs the opposite way to intuition. Unix **must author** its permission because the default is umask-derived and typically world-readable — hence `mode(0o600)` at `private_file.rs:238-244`. Windows **need not**, because ledger row A11-4 measured that a plain leaf under the user profile inherits exactly three ACEs — `NT AUTHORITY\SYSTEM`, `BUILTIN\Administrators`, and the user — with **no `BUILTIN\Users`**, all inherited, zero explicit. That is already `0600`-equivalent for the only reader that matters.
So the ancestor walk would be verifying a property Windows already guarantees. And the residual thing it would catch — another **admin** on the same box — is not defensible anyway: an administrator holds `SeTakeOwnershipPrivilege` and can seize any object regardless of its DACL. Building a chain-walking ACL validator to exclude a principal who can bypass it is theatre with a real cost, and the cost is the point: **it is the code that killed the previous layer.** The `AceSize` defect exists only because that code parsed ACEs. Not parsing them removes the entire bug class rather than fixing it — the safest ACE parser is the one that is never written.
What survives is the control that is genuinely load-bearing and has direct unix parity: **per-component reparse-point rejection**. `private_file_parent.rs:54-64` rejects user-controlled symlinks and non-directories on every component of the unix path, and the deleted Windows layer carried the matching `FILE_ATTRIBUTE_REPARSE_POINT` check at `private_file_windows.rs:81-83`. This is not a confidentiality control — it is an integrity one. A junction is creatable by an unprivileged user without `SeCreateSymbolicLinkPrivilege`, and one planted on our path redirects where the product **writes**, which no ACL on the intended destination can prevent.
Recorded against `docs/phases.md:978`, which requires this contract be "decided deliberately, matching or explicitly diverging from the unix leaf-only rule": we diverge, by validating **less** than unix rather than more, and the divergence is documented in the module.
- **KTD4. Bindings come from `windows-sys 0.61` in `crates/windows` only.** (session-settled: user-directed.) Governs R9, R10. Measured: `cargo tree -p agent-desktop --target x86_64-pc-windows-msvc -i windows-sys` shows **`windows-sys 0.61.2` is already compiled into every Windows build**, transitively via `clap`'s colour support and `tracing-subscriber`; it is in `Cargo.lock:596`. Adding it to the platform crate adds **zero crates and zero supply-chain delta**. It carries everything 2.1 needs. The `windows` crate is not needed until 2.2, where `uiautomation 0.25.0` pulls it transitively.
- **KTD5. Win32 struct layouts are pinned with compile-time asserts.** Governs R7. `docs/solutions/best-practices/ffi-repr-c-struct-size-pinning.md` documents this repo's discipline, and its motivating incident is the same shape as the defect that killed the previous layer: a layout assumption that **passed CI**. Apply `const _: () = assert!(size_of::<T>() == N)` to every Win32 struct this sub-phase reads through — `TOKEN_OWNER` and `FILE_REMOTE_PROTOCOL_INFO`. Both are fixed-layout, so a size pin is sufficient. Under KTD3 no variable-length structure (`ACL`, `ACE_HEADER`, `ACCESS_ALLOWED_ACE`) is read at all, which is what removes the extent-arithmetic burden — and the bug class that came with it.
- **KTD6. Nothing initialises COM or touches User32 at library load.** Governs R2. Microsoft's DLL best-practices list names both halves explicitly: *"Initialize COM threads by using CoInitializeEx"* and *"Call functions in User32.dll"* are both prohibited inside `DllMain`, because it runs under the loader lock. `#[ctor]` inherits the prohibition — CRT initialisers run in the same context. Use one-time lazy init at the call sites KTD2 names. Repo precedent: `crates/macos/src/system/cocoa_runtime.rs:4-15` (`OnceLock<Result<(), String>>`).
- **KTD7. The DPI bootstrap does not verify itself by read-back.** Governs R2. Ledger row A10-4 measured that `SetProcessDpiAwarenessContext(PER_MONITOR_AWARE_V2)` **succeeds** on build 17763, but the read-back reports the V1 string `PROCESS_PER_MONITOR_DPI_AWARE` because `GetProcessDpiAwareness` has no V2 enumerant. Assert on the call's return, not on a query. A second call returns `ERROR_ACCESS_DENIED`, which means "already set" and must be tolerated rather than fatal.
- **KTD8. Portable assertions only in CI tests.** Governs R5, R7. `windows-latest` is now Server 2025 with VS2026 (claims-audit row C-11), while every A11 measurement was taken on Server 2019 / 17763. Rows A11-1 and A11-3 are `api-contract` and travel; **A11-2 and A11-4 are `app/provider` and do not** — A11-2 records `EnvironmentLimit: "single built-in Administrator account on this VM; a non-admin CI account could not be exercised here"`. Assert *owner equals `TokenOwner`*, never *owner differs from `TokenUser`*. Never hardcode `S-1-5-32-544` or this box's ancestor chain.
- **KTD9. The seam is routed at the primitives, not at the call sites.** Governs R6. Core's private-file operations already funnel through a small set of definitions — `write_atomic`, `open_private_append`, `open_private_lock`, `read_private_bounded` in `crates/core/src/private_file.rs`, and `ensure_private` in `crates/core/src/private_file_parent.rs`. `crates/core/src/refs.rs:204-206` (`write_private_file`) alone funnels most write sites. Routing at the primitives covers every current and future caller — including `file_lock.rs` and `session/liveness.rs:48`, which a call-site list misses — with zero call-site edits. The seam's write operation is the **whole** `write_atomic(path, bytes)`, not a `(source, destination)` replace primitive, so the Windows implementation owns temp creation, handle lifetime, and replace ordering end to end.
### High-Level Technical Design
The seam, routed at core's primitives:
```mermaid
flowchart TB
subgraph callers["callers (unchanged)"]
RS["RefStore"]
SM["session manifest"]
TR["trace writer"]
TB["artifact budget"]
CG["clipboard image"]
FL["file_lock / liveness"]
end
subgraph core["agent-desktop-core (no Win32, no windows-sys)"]
P["private_file.rs / private_file_parent.rs<br/>write_atomic · open_private_append<br/>open_private_lock · read_private_bounded · ensure_private"]
T["trait PrivateFileOps<br/>default impl = today's portable behaviour"]
P --> T
end
RS --> P
SM --> P
TR --> P
TB --> P
CG --> P
FL --> P
subgraph win["agent-desktop-windows"]
W["WindowsPrivateFile<br/>descriptor · replace · owner · locality · acl"]
end
subgraph hosts["install sites a consumer cannot skip"]
B["binary: top of run()"]
F["cdylib: build_adapter() windows arm"]
end
W -. implements .-> T
B -- installs --> W
F -- installs --> W
```
Apartment ownership across the two consumers:
```mermaid
flowchart LR
CLI["CLI run()"] -->|"CoInitializeEx(MTA)<br/>owns its process"| A["MTA"]
FFI["cdylib build_adapter()<br/>windows arm"] -->|"CoIncrementMTAUsage<br/>cookie, thread-agnostic"| A
FFI -.->|"RPC_E_CHANGED_MODE:<br/>borrow host apartment,<br/>never uninitialise"| A
A --> S["WindowsAdapterSession<br/>owns lifetime, releases once"]
S --> N["2.2+ consume via<br/>UIAutomation::new_direct()"]
```
### Output Structure
```
crates/windows/src/
├── adapter.rs # thin: unit struct only (mirror macos/src/adapter.rs)
├── system/
│ ├── mod.rs
│ ├── adapter.rs # SystemOps impl (mirror macos/src/system/adapter.rs)
│ ├── com_runtime.rs # KTD2/KTD6 apartment bootstrap, OnceLock
│ ├── dpi.rs # KTD7 DPI awareness
│ ├── session.rs # WindowsAdapterSession
│ ├── permissions.rs # cfg-split mod imp + pure mapping fns
│ ├── permissions_tests.rs
│ └── private_file/
│ ├── mod.rs # WindowsPrivateFile: impl of core's trait
│ ├── path.rs # R8: per-component reparse-point rejection
│ ├── replace.rs # whole write_atomic: temp → drop → ReplaceFileW
│ ├── owner.rs # TokenOwner comparison (owner only, no DACL)
│ ├── locality.rs # class 13 behind a control call
│ └── tests.rs # no acl.rs, no descriptor.rs — see KTD3
crates/core/src/
└── private_file_ops.rs # trait + portable default impl + install/accessor
```
Per-unit `**Files:**` lists are authoritative; this tree is a scope declaration.
---
## Implementation Units
### U1. Make the binary-crate tests runnable on Windows
- **Goal:** `cargo test -p agent-desktop` runs on Windows, which it does not today. Hard prerequisite for U2 — without it the extended lane lands red on its first run.
- **Requirements:** R1, R4.
- **Dependencies:** none.
- **Files:** `src/tests/snapshot_test.rs`.
- **Approach:** `src/tests/snapshot_test.rs:6-16` resolves the binary by walking `current_exe()` up two levels and pushing `"agent-desktop"`, with no `std::env::consts::EXE_SUFFIX`. On Windows the artifact is `agent-desktop.exe`, so the `assert!(path.is_file(), …)` fires and **3 of 128 tests panic before running**: `version_command_outputs_json`, `snapshot_invalid_root_ref_format_returns_invalid_args`, and `list_apps_on_non_macos_errors_gracefully` — the last of which is precisely the R4 honesty assertion. The correct pattern is one file over: `src/tests/cli_process.rs:4` uses `env!("CARGO_BIN_EXE_agent-desktop")`, which Cargo resolves per-platform. Adopt it rather than appending `EXE_SUFFIX` by hand — it removes the `current_exe()` walk entirely and cannot drift.
- **Patterns to follow:** `src/tests/cli_process.rs:4`.
- **Test scenarios:**
- `cargo test -p agent-desktop` passes 128/128 on Windows.
- `list_apps_on_non_macos_errors_gracefully` actually executes and observes `PLATFORM_NOT_SUPPORTED` — it is currently dead on Windows, and it is what demonstrates R4's adapter-backed clause.
- The same target still passes on macOS, proving the helper change is platform-neutral.
- **Verification:** the three named tests pass on Windows; no test target regresses on macOS.
### U2. Extend the Windows CI lane to the full package surface
- **Goal:** `test-windows` enforces what `docs/phases.md:973` requires, so later sub-phases cannot land Windows regressions no lane executes.
- **Requirements:** R1, R10.
- **Dependencies:** U1.
- **Files:** `.github/workflows/ci.yml`, `src/cli/contract_tests.rs`.
- **Approach:**
1. **Hoist HOME isolation to a lane-wide step.** Today `$env:HOME`/`$env:USERPROFILE` are set inside the single test step's `run:` block (`ci.yml:253-261`), and **environment set in one `run:` block does not survive into the next step**. Add a first step that creates the temp home and exports `HOME`, `USERPROFILE`, `CARGO_HOME`, `RUSTUP_HOME` via `Add-Content -Path $env:GITHUB_ENV`, then drop the inline assignments — otherwise the newly added `cargo test -p agent-desktop`, which spawns the real binary and writes refmaps and trace segments, runs against the runner's real profile.
2. Add clippy over the four crates. Measured green on Windows today at exit 0. `agent-desktop-ffi` is a workspace member but **not** in `default-members` (`Cargo.toml:3`) and must be named explicitly. No `rustup component add` is needed — `rust-toolchain.toml` declares `clippy` and `rustfmt`.
3. Add `cargo test --locked -p agent-desktop` (not `--lib`: that crate has no lib target, so `--lib` silently skips it).
4. Add the core-isolation check, widened per R10 in **both** directions the current one misses: run `cargo tree` with `--edges normal,build,dev` (the existing `--edges normal,build` excludes dev-dependencies, so a `windows-sys` dev-dependency supporting `#[cfg(windows)]` test code passes clean) and with `--target x86_64-pc-windows-msvc`; then add a **source-level** gate over `crates/core/src/**` failing on `extern "system"`, `std::os::windows`, or `cfg(windows)` / `cfg(target_os = "windows")` outside an allowlist naming the two existing portable shims at `private_file.rs:207` and `:224`. Without the second gate, R10's `#[cfg(windows)]` half is asserted in the DoD but mechanized nowhere, and hand-written externs need no binding crate to slip through.
5. Add a `cargo build --locked --release -p agent-desktop` step (the size check has nothing to measure without it), then a Windows-native size check. The existing gate (`ci.yml:167-178`) uses BSD `stat -f%z`, which **fails under Git Bash** — write it in pwsh, consistent with `release.yml:341-347`: `(Get-Item target/release/agent-desktop.exe).Length` against `15MB`. There is **no helper binary on Windows**, so the step checks one file. Current headroom: 1,920,512 B, 12.2% of cap.
6. Add a `target/` cache mirroring `ci.yml:105-112` and raise the 20-minute timeout — clippy `--all-targets` plus binary tests plus an LTO release build will not fit.
- **Execution note:** `src/cli/contract_tests.rs:83` `include_str!`s `ci.yml`, so any edit forces a binary-crate rebuild. Its four assertions cover the macOS version-check step at `ci.yml:154-163`, which this unit must **not** alter. Run `cargo test -p agent-desktop` after editing the workflow.
- **Patterns to follow:** `ci.yml:114-121` (isolation, as the base to widen), `release.yml:341-347` (pwsh size measurement), `ci.yml:105-112` (target cache).
- **Test scenarios:**
- The lane passes on a PR with no Rust changes.
- A deliberate clippy warning in `crates/windows` fails the lane.
- A stub adding `windows-sys` to `crates/core/Cargo.toml` as a **dev-dependency** fails the widened tree check — the case the current `--edges normal,build` misses.
- A stub adding `extern "system"` to a core source file fails the source-level gate, while the two allowlisted shims still pass.
- The size step reports a byte count and passes; artificially lowering the cap fails it.
- A test that writes a private artifact observes it under `$RUNNER_TEMP`, not the runner profile — proving the hoisted HOME reached every step.
- **Verification:** all new checks appear in the lane and **each has been observed failing** when its condition is violated.
### U3. Add target-gated Win32 bindings to the platform crate
- **Goal:** `crates/windows` can call Win32 without any binding crate reaching core.
- **Requirements:** R9, R10.
- **Dependencies:** none.
- **Files:** `crates/windows/Cargo.toml`.
- **Approach:** Add `windows-sys` under `[target.'cfg(target_os = "windows")'.dependencies]`, mirroring `crates/macos/Cargo.toml`. Version `0.61` — already in `Cargo.lock:596`, so this resolves to the same compiled crate. Features: `Win32_Foundation`, `Win32_System_Com`, `Win32_UI_HiDpi`, `Win32_Storage_FileSystem`, `Win32_Security`, `Win32_Security_Authorization`. Do **not** add `uiautomation` or `windows-capture` (R9).
- **Patterns to follow:** `crates/macos/Cargo.toml` target-gated dependency block.
- **Test scenarios:**
- `cargo tree -p agent-desktop-core` still shows the same 8 dependencies and no binding crate, on both `--edges normal,build,dev` and the Windows target.
- `cargo check -p agent-desktop-windows --target x86_64-pc-windows-msvc` succeeds.
- **Verification:** the widened isolation check from U2 passes; the platform crate compiles against the new features.
### U4. Establish the COM apartment and DPI awareness at an unskippable call site
- **Goal:** A Windows process running this product is in the MTA and DPI-aware before any UIA or window work, with the CLI and cdylib each using the primitive their constraints require — installed where a consumer cannot skip it.
- **Requirements:** R2.
- **Dependencies:** U3.
- **Files:** `crates/windows/src/system/com_runtime.rs`, `crates/windows/src/system/dpi.rs`, `crates/windows/src/system/mod.rs`, `crates/windows/src/lib.rs`, `src/main.rs`, `crates/ffi/src/adapter.rs`.
- **Approach:**
- **Apartment (KTD2).** Expose two entry points from `com_runtime.rs`: one for a process the product owns (CLI) calling `CoInitializeEx(null, COINIT_MULTITHREADED)`, and one for a hosted library (cdylib) calling `CoIncrementMTAUsage` and retaining the cookie. `RPC_E_CHANGED_MODE` from the CLI path is **not** an error — COM is already initialised on this thread in another mode; record the apartment as borrowed and do not schedule a `CoUninitialize`. Guard each with `OnceLock<Result<…>>`, and document the asymmetry that makes this sound: `CoIncrementMTAUsage`'s cookie is **process-wide**, so a process-wide `Once` is correct; `CoInitializeEx`'s effect is **thread-local**, so the CLI path's `Once` is only valid because the CLI initialises on its main thread and does not hand the apartment to other threads.
- **DPI (KTD7).** `SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)`. Treat `ERROR_ACCESS_DENIED` as benign "already set". **Do not** verify by reading awareness back.
- **Wiring (KTD2, KTD6).** Export `ensure_com_mta_and_dpi()` from `agent-desktop-windows`. Call it from the top of `run()` in `src/main.rs`, and from the `#[cfg(target_os = "windows")]` arm of `build_adapter()` at `crates/ffi/src/adapter.rs:115-118`**not** from `ad_init`, which is documented optional and initialises nothing. Leave `ad_init` untouched so the committed header and the `ffi-header-drift` job are unaffected. **Never** call from `DllMain` or a `#[ctor]`.
- **Execution note:** Write the `RPC_E_CHANGED_MODE` and `ERROR_ACCESS_DENIED` tolerance paths test-first. They are the branches a happy-path implementation silently gets wrong, and they are the reason the cdylib works inside an STA host.
- **Patterns to follow:** `crates/macos/src/system/cocoa_runtime.rs:4-15` for the `OnceLock<Result>` shape; `crates/ffi/src/adapter.rs:107-118` for the macOS arm calling `ensure_cocoa_multithreaded()?` before adapter construction — mirror it exactly; `crates/macos/src/system/permissions.rs:12-130` for the `#[cfg]`-split `mod imp` so the crate unit-tests on any host.
- **Test scenarios:**
- Calling the CLI bootstrap twice on the same thread succeeds both times and schedules exactly one release.
- A simulated `RPC_E_CHANGED_MODE` yields success with "apartment borrowed" recorded and **no** uninitialise scheduled.
- A simulated `ERROR_ACCESS_DENIED` from the DPI call is reported as success-with-already-set, not failure.
- No test asserts V2 via read-back (KTD7) — assert on the call result.
- Constructing an FFI adapter **without any prior `ad_init` call** still observes an initialised apartment.
- The non-Windows `mod imp` arm compiles and returns canned values, so these tests run on any lane.
- **Verification:** the bootstrap runs on the Windows CI lane; no code path calls into COM or User32 from `DllMain` or a constructor; `ad_init` is unchanged.
### U5. Give the adapter a session that owns apartment lifetime
- **Goal:** `WindowsAdapterSession` implements `AdapterSession` and owns the COM apartment, so 2.2+ consume an established apartment.
- **Requirements:** R3.
- **Dependencies:** U4.
- **Files:** `crates/windows/src/system/session.rs`, `crates/windows/src/system/adapter.rs`, `crates/windows/src/system/mod.rs`.
- **Approach:** `AdapterSession` is one method — `close(self: Box<Self>) -> Result<(), AdapterError>` (`crates/core/src/adapter_session.rs:9-11`) — with **no `Drop` bound, no documented drop contract, and no test** covering a dropped-without-`close` box. Release must happen exactly once on either path. Note the Rust constraint: **a type implementing `Drop` cannot have fields moved out of it**, so `close(self: Box<Self>)` cannot simply take the cookie by value if `Drop` is also implemented. Hold the cookie as `Option<Cookie>` and have both `close()` and `Drop` take it via `Option::take()`, so the release is once-only by construction of the `Option`, not by a claim. This is also the second reason KTD2's `CoIncrementMTAUsage` is right for the hosted path: its cookie may be released from a different thread than acquired it, so a `Drop` impl is sound where `CoUninitialize` from the wrong thread would not be.
- **Execution note:** `open_session` currently has **zero production callers** — confirmed across `src/`, `crates/ffi/`, and every platform crate. This unit ships a type reachable from its own tests and from a future FFI caller; `crates/ffi/src/adapter.rs:19-23` (`AdAdapter`) is its natural future home, driven from `ad_adapter_destroy`. State that in the code so a later reader does not mistake it for dead code.
- **Patterns to follow:** `crates/core/src/adapter_session_tests.rs:8-36` (`FlagSession`) — the only existing implementation.
- **Test scenarios:**
- `open_session` returns a session rather than `PLATFORM_NOT_SUPPORTED` on Windows.
- `close()` releases exactly once, asserted via a fake release counter.
- A session dropped **without** `close()` releases exactly once.
- A session `close()`d and then dropped releases exactly once total, not twice.
- The returned box satisfies `Send + Sync`.
- **Verification:** construct/close/drop paths unit-tested on the Windows lane; the release counter proves once-only on every path.
### U6. Report Windows permissions truthfully
- **Goal:** A permission probe that reports what Windows actually constrains, unit-tested against mocked security state on a seam needing no desktop.
- **Requirements:** R4, R5.
- **Dependencies:** U3.
- **Files:** `crates/windows/src/system/permissions.rs`, `crates/windows/src/system/permissions_tests.rs`, `crates/windows/src/system/adapter.rs`.
- **Approach:** Mirror the macOS structure: a `#[cfg(target_os = "windows")] mod imp` with a `#[cfg(not(…))]` twin returning canned values, plus **pure mapping functions** taking integers and returning `PermissionState`. Those pure functions are what "unit-tested against mocked COM security state" can mean here — there is no COM mocking framework in this repo, and `crates/macos/src/system/permissions.rs:237-246` tested at `permissions_tests.rs:11-19` against literal `OSStatus` values is the only shape with prior art. Write `map_uia_access(hresult: i32) -> PermissionState` and test against literal `0x80070005`.
What to report: **accessibility** — UIA needs no special permission for most apps, so `NotRequired` or `Granted` rather than `Unknown`; **screen_recording** — no macOS-style TCC field, so derive from capture-API availability per `docs/phases.md:940`; **automation**`NotRequired`.
Two constraints from 2.0's evidence. Row **A9-2** measured that across a real Medium→High integrity boundary, **UIA reads succeed byte-identically** — so an integrity mismatch must **not** flip `accessibility` to `Denied`; it is an input-time `PERM_DENIED` that 2.6 owns. Row **A9-3** measured `SendInput` returning "6 events accepted, lastError 0" in **both** arms — never model permission off a Win32 return value.
Also override `unknown_accessibility_means_unsupported`. It is consulted **only by the FFI** (`crates/ffi/src/adapter.rs:276-293`); `src/command_policy/mod.rs` never calls it, so CLI and FFI diverge for an unprobed adapter. Overriding it makes them agree.
- **Patterns to follow:** `crates/macos/src/system/permissions.rs` in full — the `mod imp` split (`:12-130`), the pure mappers (`:194-246`), deadline checks around every native read (`:295-301`), and dependency injection for testability (`:147-172`).
- **Test scenarios:**
- `map_uia_access` maps `E_ACCESSDENIED (0x80070005)` to `Denied` with a non-empty suggestion, and `S_OK` to `Granted`.
- An unrecognised HRESULT maps to `Unknown`, not to a guess.
- `permission_report` on the non-Windows arm returns the canned shape, so the test runs on any lane.
- `automation` reports `NotRequired` on Windows.
- A denial's `platform_detail` matches the Invariant-8 HRESULT format (`docs/phases.md:833`).
- `unknown_accessibility_means_unsupported` returns the value that makes CLI and FFI agree.
- **Verification:** mapping functions unit-tested against literal HRESULTs on the Windows lane; no test requires an interactive desktop.
### U7. Introduce the private-file seam in core and install it
- **Goal:** Core routes private-file operations through a trait it defines, with a default implementation identical to today's behaviour, **and both consumers install the platform implementation at a site they cannot skip**.
- **Requirements:** R6, R10.
- **Dependencies:** none for the seam; the install step depends on U8 existing to install.
- **Files:** `crates/core/src/private_file_ops.rs`, `crates/core/src/lib.rs`, `crates/core/src/private_file.rs`, `crates/core/src/private_file_parent.rs`, `src/main.rs`, `crates/ffi/src/adapter.rs`.
- **Approach:**
- **Route at the primitives, not the call sites (KTD9).** Define the trait over `write_atomic`, `open_private_append`, `open_private_lock`, `read_private_bounded` (all in `private_file.rs`) and `ensure_private` (`private_file_parent.rs`), then have those five functions dispatch through it. Every caller is covered with **zero call-site edits** — including `crates/core/src/file_lock.rs` and `crates/core/src/session/liveness.rs:48`, which a call-site enumeration misses, and every read path. `crates/core/src/refs.rs:204-206` (`write_private_file`) already funnels most writes.
- **The write operation is the whole `write_atomic(path, bytes)`**, not a `(source, destination)` replace primitive — the Windows implementation must own temp creation, handle lifetime, and replace ordering together, because `ReplaceFile` fails error 32 over an open **source** in 10 of 10 measured cases.
- **Own the temp-handle reorder.** `write_atomic_with` (`private_file.rs:84-94`) holds the source temp handle open across `replace_atomic` and drops it only afterward. Restructure so the handle is dropped **before** the replace on the portable path too — safe on unix, required on Windows. Note this also moves `write_user_atomic`, which shares the same helper.
- **Default implementation = today's portable code**, so unix and macOS behaviour is unchanged.
- **Install (KTD2).** From the top of `run()` in `src/main.rs`, before `resolve_active_session` reads private files at `src/main.rs:118`; and from the `#[cfg(target_os = "windows")]` arm of `build_adapter()` at `crates/ffi/src/adapter.rs:115-118`. **Not** `ad_init`.
- **Test override.** Production install is a `OnceLock`. A process-global `OnceLock` alone cannot support the fake-implementation test — `cargo test -p agent-desktop-core --lib` runs every core test in one process in parallel, so a global fake would be observed by sibling tests that write real private files. Add a `#[cfg(test)] thread_local!` override consulted first by the accessor, mirroring `crates/core/src/trace_artifact_budget.rs:20-35` and `crates/core/src/refs.rs:18-20`.
- **Execution note:** Characterize before changing. `crates/core/src/private_file_tests.rs:1` is `#![cfg(unix)]` — all 10 behaviour tests are unix-only. Run them before and after to prove the default implementation is behaviour-identical. When lifting `#![cfg(unix)]` for the portable subset, the module's unix-only imports at lines 4-7 (`std::os::fd::AsRawFd`, `std::os::unix::fs::{OpenOptionsExt, PermissionsExt}`) must be cfg-gated too, or the file will not compile on Windows.
- **Patterns to follow:** `crates/core/src/trace_artifact_budget.rs:20-35` (thread-local test override); `crates/core/src/adapter/mod.rs:26-28` (core defining a trait it does not implement).
- **Test scenarios:**
- Every existing unix private-file test passes through the default implementation — the assertion is "existing unix tests pass", not "the code path is unchanged", since the temp-handle reorder deliberately changes it.
- A fake implementation installed via the thread-local override observes the calls each primitive makes, while sibling tests in the same binary keep the portable default.
- The portable subset of `private_file_tests.rs` runs and passes on Windows.
- `cargo tree -p agent-desktop-core` is unchanged — no new dependency.
- Installing twice is either rejected or idempotent, and the behaviour is asserted.
- **A spawned `agent-desktop` process on Windows exercises the installed implementation** — the never-installed path is otherwise silently successful via the default, so without this the whole hardening can ship unreachable.
- **Verification:** core has no `#[cfg(windows)]` platform logic and no binding crate; all pre-existing tests pass on macOS, Linux and Windows; the install is proven reachable from both consumers.
### U8. Implement Windows private-file hardening behind the seam
- **Goal:** The Windows arm, built against 2.0's measured evidence — deliberately smaller than the layer it replaces, because most of what that layer did was verifying what Windows already guarantees.
- **Requirements:** R6, R7, R8.
- **Dependencies:** U3, U7.
- **Files:** `crates/windows/src/system/private_file/{mod,path,replace,owner,locality,tests}.rs`.
- **Approach:** Four behaviours drive four modules. Note what is **absent** and why (KTD3): there is no descriptor authoring and no DACL validation. Windows already inherits SYSTEM + Administrators + user with no `BUILTIN\Users` under the profile, so authoring re-states it and validating re-checks it — and the validator is precisely the code whose `AceSize` handling failed last time. Not parsing ACEs removes that bug class outright.
1. **Path integrity (`path.rs`, R8).** Reject any component carrying `FILE_ATTRIBUTE_REPARSE_POINT`. Open each with `FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS` and check the attribute rather than following the link. This is the direct analogue of the unix per-component rule at `private_file_parent.rs:54-64`, which rejects user-controlled symlinks and non-directories, and it restores the check the deleted layer carried at `private_file_windows.rs:81-83`.
This is an **integrity** control, not a confidentiality one, and that distinction is why it survives the KTD3 cut while ACL validation does not: a junction is creatable by an unprivileged user without `SeCreateSymbolicLinkPrivilege`, and one planted on our path redirects where the product **writes**. No ACL on the intended destination prevents that, because the write never reaches the intended destination.
2. **Atomic replace (`replace.rs`).** Use `ReplaceFileW`, **not** `MoveFileEx`. Measured matrix (42/42 definite, `SuccessWithoutShareDelete: []`):
| op | open handle on | share mode | result |
|---|---|---|---|
| `MoveFileEx` | target | any, incl. `0x4`/`0x7` | **`ERROR_ACCESS_DENIED (5)`** |
| `MoveFileEx` | source | `0x4`/`0x7` | success |
| `ReplaceFile` | target | `0x0`/`0x1`/`0x3` | `ERROR_SHARING_VIOLATION (32)` |
| `ReplaceFile` | **target** | **`0x4`/`0x7`** | **success**, held handle still reads old bytes |
| `ReplaceFile` | source | any, incl. `0x4`/`0x7` | `ERROR_SHARING_VIOLATION (32)` |
Opposite tolerances on opposite sides. Classify **error 5** as the expected destination-side sharing failure, not 32.
Dropping the source handle before the replace (required — see the last row) opens a window in which a fully-written temp sits closed on disk. Create temps inside a per-process subdirectory of the private root — which inherits the same SYSTEM/Administrators/user ACL as everything else under the profile — and sweep stale per-process temp directories at startup so an aborted process leaves no orphan. The existing hashed-nonce temp name (`private_file.rs:188`) is retained; it is what makes the name unpredictable to a same-privilege racer.
**A trap in the other direction:** plain `OpenOptions` on Windows already defaults to `FILE_SHARE_READ|WRITE|DELETE`, which is why readers holding a destination open work today. Any hardened `CreateFileW` that *narrows* that share mask re-introduces the 122-failure sharing cluster. Widen deliberately; never narrow silently.
3. **Ownership (`owner.rs`).** Validate against `TokenOwner`, never `TokenUser`. Measured at **both** High and Medium integrity: owner `S-1-5-32-544`, `OwnerMatchesTokenUser: false`, `OwnerMatchesTokenOwner: true`. **Integrity is not the variable — group membership is.** Read the owner only — `GetSecurityInfo` with `OWNER_SECURITY_INFORMATION`, no DACL requested — so this module reads a fixed-layout SID and never touches an ACE.
Its purpose is narrow and must be documented as such: it detects a path **pre-created by a foreign principal**, which is the Windows analogue of the unix `uid`/`nlink` post-condition at `private_file.rs:123-143`. On an admin-group account `TokenOwner` resolves to `BUILTIN\Administrators`, so it is explicitly **not** an isolation boundary between admin processes — nothing here is, and nothing can be, since an administrator holds `SeTakeOwnershipPrivilege`.
4. **Locality (`locality.rs`).** `GetFileInformationByHandleEx(FileRemoteProtocolInfo)` — class **13** — distinguishes local from remote, but signals *local* by **failing** with `ERROR_INVALID_PARAMETER (87)` (0/6 local returned data; 3/3 remote did). The trap: an out-of-range class (tested with 55) returns **the same 87** on all 9 targets. So 87 is a locality signal only **behind a control call on a known-good class** — issue `FileBasicInfo` (class 0) on the same handle first and require it to succeed. **Third state:** if the control call itself fails, the verdict is `Unknown`, and `Unknown` is treated as **remote (refused)** for private artifacts — failing open would write private data to SMB storage on a redirected profile.
**What is deliberately not here.** The deleted layer's `private_file_windows_security.rs` — DACL enumeration, owner-only descriptor construction, and the `GetAce` loop whose blind cast at `:91` is the defect this sub-phase is haunted by — has **no successor module**. Under KTD3 nothing in 2.1 calls `GetAce`, `GetAclInformation`, `InitializeAcl`, or `AddAccessAllowedAceEx`. If a later sub-phase needs DACL inspection, it inherits the extent-arithmetic obligation then; 2.1 does not, because it reads no variable-length security structure. Apply KTD5 size pins to `TOKEN_OWNER` and `FILE_REMOTE_PROTOCOL_INFO`, both fixed-layout.
- **Execution note:** Write the junction-redirection test **first**, before any path code. Plant a junction inside a temp private root, point it elsewhere, and assert the write is refused. It needs no privilege and no desktop, it is the regression test for the one attack this unit genuinely defends, and writing it first is what keeps `path.rs` from degrading into a check that follows the link it is supposed to reject.
- **Patterns to follow:** `docs/solutions/best-practices/ffi-repr-c-struct-size-pinning.md` (KTD5, fixed-layout structs only); `docs/solutions/best-practices/never-ship-platform-code-that-ci-cannot-execute.md` — a `#[cfg]` branch CI cannot execute is a hypothesis, and a test comparing constants to constants can never fail.
- **Test scenarios:**
- **A junction planted on the path is refused.** Create a temp private root, plant a junction inside it pointing elsewhere, and assert the write is refused rather than landing at the junction target. No privilege required.
- A path component that is a regular file where a directory is expected is refused.
- A newly created artifact under the profile inherits SYSTEM, Administrators and the user and **no `BUILTIN\Users`** — asserted structurally by principal class, never against literal SIDs (KTD8). This is the assumption KTD3 rests on; it is checked once in tests so a future OS change breaks the test rather than silently degrading the product.
- `ReplaceFileW` succeeds over a destination held open **with** `FILE_SHARE_DELETE`, and the held handle still reads the **old** bytes.
- `ReplaceFileW` fails **32** over a destination held open **without** share-delete, and **32** over an open **source** even with share-delete.
- `MoveFileExW` fails **5**, not 32, over an open target even at share mask `0x7`.
- Class 13 returns 87 on a local NTFS temp file **while** control class 0 succeeds on the same handle; an out-of-range class also returns 87; a forced control-call failure yields `Unknown` and the write is refused.
- A freshly created file's owner **equals `TokenOwner`** (KTD8 — never assert inequality to `TokenUser`).
- Size pins fail the build if an upstream fixed-layout struct changes.
- **A negative test asserts the absence**: no symbol from the ACE/ACL family (`GetAce`, `GetAclInformation`, `InitializeAcl`, `AddAccessAllowedAceEx`) appears anywhere in `crates/windows/src/system/private_file/`. KTD3's whole benefit is that this code does not exist, so a grep-level guard keeps a future well-meaning addition from reintroducing the bug class without a decision.
- **Verification:** every scenario runs on the `windows-latest` lane and is observed failing when its condition is violated; no assertion hardcodes a machine-specific SID or ancestor chain.
---
## Verification Contract
| Gate | Command / check | Applies to |
|---|---|---|
| Repo gates (Windows dev box) | `cargo fmt --all -- --check`; `cargo clippy --locked -p agent-desktop-core -p agent-desktop-windows -p agent-desktop -p agent-desktop-ffi --all-targets -- -D warnings`; `cargo test --locked -p agent-desktop-core -p agent-desktop-windows --lib` | whole PR |
| Windows lane | clippy over four crates, `cargo test -p agent-desktop`, widened isolation, release build + native size check — each observed failing when violated; every step under an isolated `HOME` | U1, U2 |
| Core isolation | `cargo tree -p agent-desktop-core --edges normal,build,dev` on the Windows target contains no platform crate and no binding crate; the source-level gate rejects unallowlisted `cfg(windows)` / `extern "system"` in core | U2, U3, U7 |
| Install reachability | a spawned `agent-desktop` process and an FFI adapter constructed **without** `ad_init` both exercise the installed Windows implementation | U4, U7 |
| Bootstrap honesty | `RPC_E_CHANGED_MODE` and `ERROR_ACCESS_DENIED` tolerated with tests; no read-back assertion of DPI V2; no COM or User32 call from `DllMain`/`#[ctor]`; `ad_init` unchanged | U4 |
| Session lifetime | release runs exactly once on close, on drop, and on close-then-drop | U5 |
| Private-file evidence | every measured behaviour in U8 has a test that can fail; junction-redirection refusal covered; inherited-ACL assumption asserted structurally | U8 |
| No ACE parsing | no `GetAce` / `GetAclInformation` / `InitializeAcl` / `AddAccessAllowedAceEx` symbol appears under `crates/windows/src/system/private_file/` (KTD3) | U8 |
| Portability of assertions | no test asserts an `app/provider` fact — no hardcoded SID, no hardcoded ancestor chain, no inequality-to-`TokenUser` | U6, U8 |
| Behaviour preservation | all pre-existing unix private-file tests pass through the new seam | U7 |
| Size | Windows release binary under 15 MiB (currently 1,920,512 B, 12.2%) | U2 |
| PR is green | every required check on a PR into `feat/windows-adapter`, never `main` | whole PR |
**Pre-commit note.** `.githooks/pre-commit` runs unqualified `cargo clippy --all-targets -- -D warnings` and `cargo test --lib --workspace`, both of which resolve through `default-members` and therefore **fail on a Windows dev box** (112 and 186 errors) until ledger row R6-3 lands. Commit with `SKIP_PRECOMMIT=1` on Windows and run the package-scoped forms in the Repo gates row manually in its place.
## Definition of Done
- A PR from `feat/windows-2.1-bootstrap` into `feat/windows-adapter` is open and green.
- The Windows lane enforces clippy over the four crates, binary-crate tests, widened core-isolation, and a native size check — each observed failing when its condition is violated — with every step under an isolated `HOME`.
- `WindowsAdapter` constructs and satisfies `PlatformAdapter`; every **adapter-backed** command returns honest `PLATFORM_NOT_SUPPORTED`, while `version`, `skills`, `session`, `trace`, `status` and `permissions` continue to succeed.
- COM apartment and DPI awareness are established at a call site no consumer can skip, with the borrowed-apartment and already-set paths tested, and `ad_init` unchanged.
- `WindowsAdapterSession` owns apartment lifetime and releases exactly once on every path.
- The permission probe reports truthfully and its mapping functions are unit-tested against literal HRESULTs.
- Private-file hardening exists behind a core-defined seam, is **proven reachable from both consumers**, rejects reparse points per component, and implements every measured behaviour in R7 — **unit-tested on the `windows-latest` lane**, not merely `cargo check`-clean.
- No ACE or ACL parsing exists in the Windows private-file surface, and a test asserts its absence (KTD3).
- `agent-desktop-core` contains no Win32 binding crate and no unallowlisted `#[cfg(windows)]` platform logic, **both mechanically gated**.
- No self-hosted runner was registered; ledger row A10-2 remains open and owned by 2.12.
---
## Risks & Dependencies
- **The seam is the largest structural change.** Routing at the five primitives keeps call sites untouched, and the default implementation is today's code — but the temp-handle reorder deliberately changes the portable path, so "existing unix tests pass" is the real assertion, not "nothing changed."
- **`unsafe_op_in_unsafe_fn = "warn"` becomes an error under `-D warnings`.** U2's "clippy exits 0" measurement predates U3 and U8. Once real `unsafe` Win32 code lands in `crates/windows`, that workspace lint (`Cargo.toml:26`) bites. Budget for it rather than discovering it in CI.
- **Ten new files under `crates/windows/src/system/` in one PR**, all subject to the 400-LOC rule enforced by `scripts/check-rust-file-size.sh`. Dropping the DACL surface (KTD3) removes the file most likely to have exceeded it.
- **KTD3 is a bet on inherited ACLs, and it is stated as one.** If a machine's profile chain is misconfigured — an unusual domain policy, an odd roaming-profile setup — the product writes artifacts more readable than intended and nothing detects it. The alternative was a validator that fails closed on the same machine, and this repo has already shipped that: `classify_locality` killed `status` on ordinary local disk. Between silently-permissive and loudly-broken on a legitimate configuration, permissive is the correct failure for a desktop automation CLI. The structural test in U8 pins the assumption so an OS behaviour change breaks a test rather than the product.
- **`windows-latest` is Server 2025; 2.0 measured Server 2019.** API-contract rows travel; `app/provider` rows do not. KTD8 is the guard, and it is the most likely place a well-meaning test hardcodes an environment artifact.
- **The CI lane runs as a single administrator**, so every ACL and ownership assertion is exercised from a token that already dominates the objects under test. No lane demonstrates the controls deny a lower-privileged principal. The controls are still worth having; the coverage claim must not be overstated.
- **The previous layer failed on second-order effects.** Every one of its four clusters was a correct-looking call with a wrong assumption underneath. If a case is not in the measured matrices, measure it with the 2.0 probes rather than reasoning about it.
- **`uiautomation` will re-initialise the apartment in 2.2** unless it uses `new_direct()`. `docs/phases.md:999` and `:1105` now specify that; restate it at 2.2's start.
## Open Questions
- **Workspace `default-members` includes `crates/macos`** (`Cargo.toml:3`), so `cargo build --release` and `cargo test --lib --workspace` — both documented in `CLAUDE.md` — fail on Windows and Linux. Recorded as ledger row R6-3, out of scope here because it changes unqualified cargo behaviour on every platform. Its working consequence for this PR is handled by the Verification Contract's pre-commit note.
- **`trace_read/html.rs:253` bypasses the private-file primitives entirely**, hand-rolling temp+rename with a predictable temp name (`html.rs:227`) instead of the hashed nonce (`private_file.rs:188`). Routing at the primitives does not catch it because it never calls them. Real defect; needs its own decision about whether trace HTML export is a private artifact.
- **Does the seam cover `write_user_atomic` / `write_user_file`** (`crates/core/src/refs.rs:208`)? Both share `write_atomic_with`, so U7's temp-handle reorder moves the user-output path whether or not it is in scope.
- ~~Is cross-admin isolation in scope?~~ **Decided: no.** An administrator holds `SeTakeOwnershipPrivilege` and can seize any object regardless of its DACL, so no file-permission control can exclude one. macOS gives the same answer implicitly — root reads anything. The product's confidentiality boundary is *other non-admin users*, which profile inheritance already provides. KTD3 and `owner.rs` are written to that boundary and say so.
- **Whether the FFI should wire `open_session`.** U5 ships a session reachable only from tests; `crates/ffi/src/adapter.rs:19-23` is its natural home. Deferred rather than widening 2.1 into `crates/ffi` beyond the install call.
## Sources & Research
- `docs/phases.md` §2.1 (`:968-986`), Cross-cutting DoD (`:942-952`), Windows Engineering Invariants (`:826-838`), recorded pins (`:1314-1344`) — corrected in `31ffd5f` and `4206c72`.
- `probes/windows/FINDINGS.md` rows A8-3, A8-4, A9-1, A9-2, A9-3, A10-1, A10-2, A10-4, A11-1, A11-2, A11-3, A11-4, and session-evidence rows R6-1…R6-11, C-11, C-13.
- `probes/windows/captures/12-private-file-io/*.json` — the share-mode matrix, ownership readings, locality results and ACL chain transcribed in U8.
- Commit `8ad66b8` (PR #106) — the deleted layer; `private_file_windows_security.rs:87-101` at `8ad66b8^` for the recovered defect; `private_file_windows.rs:81-83` for the reparse-point rejection it carried.
- `docs/solutions/best-practices/never-ship-platform-code-that-ci-cannot-execute.md`; `docs/solutions/best-practices/ffi-repr-c-struct-size-pinning.md`; `docs/solutions/best-practices/real-app-tests-are-the-platform-adapter-gate.md`.
- Microsoft Learn, fetched 2026-07-26: [CoIncrementMTAUsage](https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-coincrementmtausage), [CoInitializeEx](https://learn.microsoft.com/en-us/windows/win32/api/combaseapi/nf-combaseapi-coinitializeex), [UIA threading](https://learn.microsoft.com/en-us/windows/win32/winauto/uiauto-threading), [SetProcessDpiAwarenessContext](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setprocessdpiawarenesscontext), [DLL best practices](https://learn.microsoft.com/en-us/windows/win32/dlls/dynamic-link-library-best-practices).
- crates.io and docs.rs, fetched 2026-07-26: `windows-sys` 0.61.2 feature list and function inventory; `uiautomation` 0.25.0 constructor documentation.
- Measured locally 2026-07-26: `cargo tree -p agent-desktop --target x86_64-pc-windows-msvc -i windows-sys`; clippy over the four crates (exit 0); release binary 1,920,512 B.

View file

@ -320,8 +320,8 @@ try {
UserInteractive = [Environment]::UserInteractive
}
Reason = 'This VM runs on the physical console: SESSIONNAME=Console, UserInteractive=True, one interactive session. An RDP session transition cannot be produced here without disconnecting the very session the probe corpus runs in, so no honest observation of remote-session behavior is available from this environment (KTD3).'
ClosurePoint = 'Sub-phase 2.1 runner registration. Registering the Windows CI runner in 2.1 creates the second, non-console session environment; the RDP session-transition facet is measured there and this row closes against that runner. Until then no Windows adapter behavior may assume console-session semantics.'
PhasesAction = 'phases.md must not claim RDP/remote-session support for the Windows adapter until 2.1 runner evidence exists.'
ClosurePoint = 'Sub-phase 2.12 runner registration. Registering the Windows CI runner in 2.12 creates the second, non-console session environment; the RDP session-transition facet is measured there and this row closes against that runner. Until then no Windows adapter behavior may assume console-session semantics.'
PhasesAction = 'phases.md must not claim RDP/remote-session support for the Windows adapter until 2.12 runner evidence exists.'
}
$deferredPath = Write-ProbeJson -Probe $Probe -Name 'deferred-rdp.json' -InputObject $deferred

View file

@ -259,7 +259,7 @@ try {
activeConsoleSessionId = [int][AgentDesktopProbe.Dpi]::WTSGetActiveConsoleSessionId()
isRemoteSession = ([AgentDesktopProbe.Dpi]::GetSystemMetrics(0x1000) -ne 0)
windowStation = ([System.Environment]::GetEnvironmentVariable('SESSIONNAME'))
note = 'RDP session-transition behavior is not measurable here (physical console session); it closes at sub-phase 2.1 runner registration'
note = 'RDP session-transition behavior is not measurable here (physical console session); it closes at sub-phase 2.12 runner registration'
}
Add-Type -AssemblyName System.Windows.Forms

View file

@ -137,7 +137,7 @@ Because there is exactly one environment (KTD2), the `scope` column is doing rea
| id | script | stack | scope | phases.md expectation | observed | verdict | action |
| --- | --- | --- | --- | --- | --- | --- | --- |
| A10-1 | `10-session-dpi.ps1` | managed | app/provider | Invariant 12, no cross-session driving, and R12, UIA requires an interactive session | `sessionId` 1 equals `activeConsoleSessionId` 1, `isRemoteSession` false, window station `Console`, `UserInteractive` true. The whole corpus therefore ran in a genuinely interactive console session, which is the precondition every input and hit-test row depends on | CONFIRMS | the corpus's interactivity precondition is measured, not assumed |
| A10-2 | `00-environment.ps1` | n/a | app/provider | R12: an RDP disconnect can drop the console session to a non-interactive state, with `tscon` as the documented workaround | measured environmental fact: this VM runs on the physical console with one interactive session. An RDP session transition cannot be produced here without disconnecting the very session the corpus runs in, so no honest observation is available | DEFERRED | closure: 2.1 - registering the Windows CI runner creates the second, non-console session environment where this facet is measured. Until then no Windows adapter behavior may assume console-session semantics, and phases.md must not claim RDP or remote-session support |
| A10-2 | `00-environment.ps1` | n/a | app/provider | R12: an RDP disconnect can drop the console session to a non-interactive state, with `tscon` as the documented workaround | measured environmental fact: this VM runs on the physical console with one interactive session. An RDP session transition cannot be produced here without disconnecting the very session the corpus runs in, so no honest observation is available | DEFERRED | closure: 2.12 - registering the Windows CI runner creates the second, non-console session environment where this facet is measured. Until then no Windows adapter behavior may assume console-session semantics, and phases.md must not claim RDP or remote-session support |
| A10-3 | `10-session-dpi.ps1` | managed | app/provider | 2.4 owns `list_displays` and per-monitor `scale_factor`; 2.0 area 10 requires DPI and multi-monitor bounds behavior | this display reports no EDID, registry key prefix `NOEDID_15AD_0405`, and offers exactly one scale step: minimum, current and maximum relative scale are all 0, with zero steps above recommended. Requesting 125 pct makes `DisplayConfigSetDeviceInfo` **return success and persist the value to the registry** while the monitor's effective DPI stays 96 in both arms. The aware-versus-unaware bounds delta is therefore a measured numeric **zero** on all four rectangle fields, with the split genuinely in force: `PROCESS_PER_MONITOR_DPI_AWARE` against `PROCESS_DPI_UNAWARE` forced by `__COMPAT_LAYER` | DEFERRED | closure: 2.4 - the sub-phase that owns `list_displays` and per-monitor `scale_factor`, on a runner with a scalable display. Carried forward regardless of closure: a successful return from `DisplayConfigSetDeviceInfo` is not evidence the scale applied, so 2.4 must verify against effective DPI |
| A10-4 | `10-session-dpi.ps1` | managed | api-contract | Invariant 1: `SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)` at startup | the V2 call **succeeds** on build 17763 from a process that starts `PROCESS_DPI_UNAWARE`. The read-back reports the V1 string `PROCESS_PER_MONITOR_DPI_AWARE`, because `GetProcessDpiAwareness` has no V2 enumerant | CONFIRMS | invariant 1 is viable on the stated 1809 floor. 2.1's bootstrap must not assert V2 by reading awareness back - the read-back cannot express it |
| A10-5 | `00-environment.ps1` | n/a | app/provider | 2.10: `Windows.Graphics.Capture` per-window screenshot requires Windows 10 1903+ | measured environmental fact: this VM is build 17763, that is 1809, below the 1903 floor, so no `Windows.Graphics.Capture` behavior can be observed here at all | DEFERRED | closure: 2.10 - on `windows-latest`, the sub-phase that owns modern capture |
@ -199,12 +199,15 @@ R7 requires the map to be bijective in both directions: every hunk maps to at le
backing row, and every `CONTRADICTS` row maps to at least one hunk.
The authoritative count is measured, not written. `git diff -U0 main -- docs/phases.md`
reports **37** hunks after this sub-phase's corrections. It reported 36 before U9's
replacements; of U9's six in-place corrections, three merged into existing adjacent hunks
under `-U0` (the private-file requirements bullet into H13, the `CacheRequest` row into
H17, and the Chromium settle clauses into H08, H16 and H36, which already touched those
exact lines), and one opened the new H37. `13-ledger-check.ps1` re-measures the count on
every run and fails if the index and the live diff disagree.
reports **43** hunks. `13-ledger-check.ps1` re-measures the count on every run and fails if
the index and the live diff disagree, so no number written here can drift.
Sub-phase 2.1's corrections to phases.md are carried in this index because the same
source-of-truth rule governs them, but not all of them are 2.0 measurements. A hunk whose
correction came from 2.1 research rather than from a probe cites the ledger row that
carries the same obligation and is labelled `research:` with its source, so a
citation-backed external fact never launders itself into a measured one. H04 and the
research clauses inside H15, H16, H19, H22 and H34 are the rows carrying that label.
The five `CONTRADICTS` rows are A1-5, A6-1, A8-3, A11-1 and A11-3; each appears below.
@ -213,40 +216,46 @@ The five `CONTRADICTS` rows are A1-5, A6-1, A8-3, A11-1 and A11-3; each appears
| H01 | `@@ -13 +13,2 @@` release table, v0.6.0 and v0.5.0 rows | C-14, R6-1, R6-4 |
| H02 | `@@ -41 +42 @@` Phase 2 status line | C-14 |
| H03 | `@@ -53 +54 @@` phase summary table row | C-14 |
| H04 | `@@ -579 +580,3 @@` CI job table, `platform-check` plus the new `test-windows` and `test-linux` lanes | C-14, R6-1, R6-6 |
| H05 | `@@ -795,2 +798,2 @@` the integration branch is the base for everything that platform does | R6-10 |
| H06 | `@@ -801 +804,2 @@` no-convenience-deferral rule and the promotion gate | A10-2, A10-3, A10-5, A10-6, A10-7 |
| H07 | `@@ -810 +814 @@` Phase 2 section status | C-14 |
| H08 | `@@ -869,2 +873,2 @@` P2-O14 Action Center mapping, and P2-O15 Chromium exposure plus the settle requirement | C-10, C-4, A1-5 |
| H09 | `@@ -873 +877 @@` P2-O18 ships inside Phase 2 | A10-2 |
| H10 | `@@ -928 +932 @@` capability map, tray overflow window class | C-5 |
| H11 | `@@ -955 +959 @@` 2.0 scope names the Notepad variant | C-9, A1-1 |
| H12 | `@@ -970 +974 @@` 2.1 extends the existing `test-windows` lane | C-13, R6-2, R6-6 |
| H13 | `@@ -974,2 +978,2 @@` 2.1 records the re-verified pins, and the 2.1 private-file requirements | R6-11, A11-1, A11-3 |
| H14 | `@@ -981 +985 @@` 2.1 exit criteria scope to the packages that build on Windows | C-12, R6-3 |
| H15 | `@@ -996 +1000 @@` 2.2 key APIs, `uiautomation` 0.25+ | R6-11, C-2 |
| H16 | `@@ -1032 +1036 @@` 2.4 Chromium detection requires a settle before judging thinness | A1-5, C-4 |
| H17 | `@@ -1102,2 +1106,2 @@` API mapping table, tree-root pin and the `CacheRequest` phase split | R6-11, A6-1, A6-2 |
| H18 | `@@ -1227 +1231 @@` npm postinstall gains `win32-arm64` | C-6 |
| H19 | `@@ -1240 +1244 @@` 2.14 title loses the stretch qualifier | A10-2 |
| H20 | `@@ -1244 +1248 @@` 2.14 ships before the 2.15 merge | A10-2 |
| H21 | `@@ -1261 +1265 @@` tray command table, overflow flyout class | C-5 |
| H22 | `@@ -1280 +1284 @@` 2.14 list-items bullet, overflow flyout class | C-5 |
| H23 | `@@ -1298 +1302 @@` 2.15 depends on all of 2.0 through 2.14 | A10-2 |
| H24 | `@@ -1306,2 +1310,2 @@` OS floors, Windows 10 servicing reality and the WGC feature floors | C-7, C-8, A10-5 |
| H25 | `@@ -1316,3 +1320,3 @@` new-dependency pin table | R6-11, C-1, C-2, C-3 |
| H26 | `@@ -1322 +1326 @@` pin re-verification note | R6-11 |
| H27 | `@@ -1335 +1339 @@` `Cargo.toml` snippet, `uiautomation = "0.25"` | R6-11, C-2 |
| H28 | `@@ -1337 +1341 @@` `Cargo.toml` snippet, `windows-capture = "2.0.0"` | R6-11, C-1 |
| H29 | `@@ -1389 +1393 @@` docs checklist, Windows permissions and Chromium 138 | C-4, A1-5 |
| H30 | `@@ -1716 +1720 @@` 3.14 title loses the stretch qualifier | A10-2 |
| H31 | `@@ -1720 +1724 @@` 3.14 ships before the 3.15 merge | A10-2 |
| H32 | `@@ -1768 +1772 @@` 3.15 depends on all of 3.0 through 3.14 | A10-2 |
| H33 | `@@ -2476,3 +2480,4 @@` CI evolution table, v0.6.0 row and the Phase 2 and 3 rows | C-14, R6-1, R6-6 |
| H34 | `@@ -2482 +2487 @@` runner enforcement scopes to the packages that build on each OS | C-12, C-13, R6-3 |
| H35 | `@@ -2493,3 +2498,3 @@` new-dependencies summary table | R6-11, C-1, C-2, C-3 |
| H36 | `@@ -2536 +2541 @@` R2 risk row, settle before judging a Chromium tree thin | A1-5, C-4 |
| H37 | `@@ -2547 +2552 @@` R13 risk row, handler removal cost is window churn rather than the barrier | A8-3 |
| H04 | `@@ -541 +542 @@` core unit-test list names the doubles that exist | R6-1 (research: a repo-wide grep finds no `MockAdapter` in any `.rs` file; the real doubles are `NoopAdapter` and per-test ad-hoc structs) |
| H05 | `@@ -579 +580,3 @@` CI job table, `platform-check` plus the `test-windows` and `test-linux` lanes, and the core-isolation check inside 2.1's lane extension | C-14, R6-1, R6-6, R6-7, C-13 |
| H06 | `@@ -795,2 +798,2 @@` the integration branch is the base for everything that platform does | R6-10 |
| H07 | `@@ -801 +804,2 @@` no-convenience-deferral rule and the promotion gate | A10-2, A10-3, A10-5, A10-6, A10-7 |
| H08 | `@@ -810 +814 @@` Phase 2 section status | C-14 |
| H09 | `@@ -823 +827 @@` Windows Engineering Invariant 1, the manifest divergence and the ban on verifying V2 by read-back | A10-4 |
| H10 | `@@ -825 +829 @@` Windows Engineering Invariant 3, event delivery is multi-threaded while registration is not | A8-4 |
| H11 | `@@ -869,2 +873,2 @@` P2-O14 Action Center mapping, and P2-O15 Chromium exposure plus the settle requirement | C-10, C-4, A1-5 |
| H12 | `@@ -873 +877 @@` P2-O18 ships inside Phase 2 | A10-2 |
| H13 | `@@ -928 +932 @@` capability map, tray overflow window class | C-5 |
| H14 | `@@ -955 +959 @@` 2.0 scope names the Notepad variant | C-9, A1-1 |
| H15 | `@@ -970,3 +974,2 @@` 2.1's lane extension and its `EXE_SUFFIX` prerequisite, the runner bullet dropped from 2.1, and the split COM/DPI bootstrap | C-13, R6-2, R6-6, A10-2, A8-4, A10-4 (research: Microsoft Learn on `CoIncrementMTAUsage`) |
| H16 | `@@ -974,2 +977,2 @@` 2.1 records the pins and scopes the embargo to `uiautomation`/`windows-capture`, and the private-file requirements with the seam stated as a constraint | R6-11, A11-1, A11-3 (research: every private-artifact write site is in core with no adapter handle, and core may not depend on the platform crate) |
| H17 | `@@ -977 +980 @@` 2.1 key APIs gain `CoIncrementMTAUsage`, `ReplaceFileW` and `GetFileInformationByHandleEx` | A11-1, A11-3 |
| H18 | `@@ -981 +984 @@` 2.1 exit criteria: package scope, adapter-backed commands and portable private-file assertions, with no runner or RDP measurement left in the gate | C-12, R6-3, R6-5, A11-2, A11-4, C-11 |
| H19 | `@@ -996 +999 @@` 2.2 key APIs, `uiautomation` 0.25+ constructed with `new_direct()` | R6-11, C-2 (research: the crate's own two constructor doc strings) |
| H20 | `@@ -1032 +1035 @@` 2.4 Chromium detection requires a settle before judging thinness | A1-5, C-4 |
| H21 | `@@ -1102,2 +1105,2 @@` API mapping table, tree-root pin plus `new_direct()`, and the `CacheRequest` phase split | R6-11, A6-1, A6-2, C-2 |
| H22 | `@@ -1211 +1214,4 @@` 2.12 registers the self-hosted interactive runner, hardens that registration for a public repository, and owns the deferred-row closure | A10-2 (research: GitHub's own guidance against self-hosted runners on public repositories) |
| H23 | `@@ -1217 +1223 @@` 2.12 exit criteria gain the runner, its written hardening policy and the RDP measurement | A10-2 |
| H24 | `@@ -1219 +1225 @@` 2.12 estimate covers runner registration alongside the fixture and harness | A10-2 |
| H25 | `@@ -1227 +1233 @@` npm postinstall gains `win32-arm64` | C-6 |
| H26 | `@@ -1240 +1246 @@` 2.14 title loses the stretch qualifier | A10-2 |
| H27 | `@@ -1244 +1250 @@` 2.14 ships before the 2.15 merge | A10-2 |
| H28 | `@@ -1261 +1267 @@` tray command table, overflow flyout class | C-5 |
| H29 | `@@ -1280 +1286 @@` 2.14 list-items bullet, overflow flyout class | C-5 |
| H30 | `@@ -1298 +1304 @@` 2.15 depends on all of 2.0 through 2.14 | A10-2 |
| H31 | `@@ -1306,2 +1312,2 @@` OS floors, Windows 10 servicing reality and the WGC feature floors | C-7, C-8, A10-5 |
| H32 | `@@ -1316,3 +1322,3 @@` new-dependency pin table | R6-11, C-1, C-2, C-3 |
| H33 | `@@ -1322 +1328 @@` pin re-verification note | R6-11 |
| H34 | `@@ -1335,3 +1341,3 @@` `Cargo.toml` snippet: `uiautomation = "0.25"`, the five `windows` features 2.1's own scope needs, and `windows-capture = "2.0.0"` | R6-11, C-1, C-2, C-3 (research: all five feature gates verified present in `windows` 0.62.2 and `windows-sys` 0.61.2 on docs.rs) |
| H35 | `@@ -1389 +1395 @@` docs checklist, Windows permissions and Chromium 138 | C-4, A1-5 |
| H36 | `@@ -1716 +1722 @@` 3.14 title loses the stretch qualifier | A10-2 |
| H37 | `@@ -1720 +1726 @@` 3.14 ships before the 3.15 merge | A10-2 |
| H38 | `@@ -1768 +1774 @@` 3.15 depends on all of 3.0 through 3.14 | A10-2 |
| H39 | `@@ -2476,3 +2482,4 @@` CI evolution table, v0.6.0 row plus the Phase 2 and 3 rows, with runner registration named at 2.12 | C-14, R6-1, R6-6, A10-2 |
| H40 | `@@ -2482 +2489 @@` runner enforcement: package scope, and clippy, core isolation and the size cap stated as macOS-only today | C-12, C-13, R6-3, R6-6, R6-7 |
| H41 | `@@ -2493,3 +2500,3 @@` new-dependencies summary table | R6-11, C-1, C-2, C-3 |
| H42 | `@@ -2536 +2543 @@` R2 risk row, settle before judging a Chromium tree thin | A1-5, C-4 |
| H43 | `@@ -2546,2 +2553,2 @@` R12 risk row, the `tscon` workaround is owed by the sub-phase that registers the runner, and R13, handler removal cost is window churn rather than the barrier | A10-2, A8-3 |
## Completeness self-check

View file

@ -1,6 +1,6 @@
{
"Probe": "00-environment",
"CapturedAtUtc": "2026-07-26T09:07:02Z",
"CapturedAtUtc": "2026-07-27T05:58:25Z",
"Area": "session and DPI/multi-monitor behavior (plan area 10): RDP session-transition behavior",
"Facet": "How UIA tree reads, window handles, foreground/focus, and input synthesis behave across an RDP connect/disconnect/reconnect session transition, and how a disconnected session differs from an attached console session.",
"Verdict": "DEFERRED",
@ -10,6 +10,6 @@
"UserInteractive": true
},
"Reason": "This VM runs on the physical console: SESSIONNAME=Console, UserInteractive=True, one interactive session. An RDP session transition cannot be produced here without disconnecting the very session the probe corpus runs in, so no honest observation of remote-session behavior is available from this environment (KTD3).",
"ClosurePoint": "Sub-phase 2.1 runner registration. Registering the Windows CI runner in 2.1 creates the second, non-console session environment; the RDP session-transition facet is measured there and this row closes against that runner. Until then no Windows adapter behavior may assume console-session semantics.",
"PhasesAction": "phases.md must not claim RDP/remote-session support for the Windows adapter until 2.1 runner evidence exists."
"ClosurePoint": "Sub-phase 2.12 runner registration. Registering the Windows CI runner in 2.12 creates the second, non-console session environment; the RDP session-transition facet is measured there and this row closes against that runner. Until then no Windows adapter behavior may assume console-session semantics.",
"PhasesAction": "phases.md must not claim RDP/remote-session support for the Windows adapter until 2.12 runner evidence exists."
}

View file

@ -1,6 +1,6 @@
{
"Probe": "00-environment",
"CapturedAtUtc": "2026-07-26T09:07:02Z",
"CapturedAtUtc": "2026-07-27T05:58:25Z",
"Purpose": "machine facts every sub-phase 2.0 ledger row inherits",
"Os": {
"ProductName": "Windows Server 2019 Datacenter",
@ -108,7 +108,7 @@
]
},
"Integrity": {
"ProcessId": 1516,
"ProcessId": 2400,
"MandatoryLabelSid": "S-1-16-12288",
"MandatoryLabelName": "High",
"EnableLua": 1,

View file

@ -7,7 +7,7 @@
"activeConsoleSessionId": 1,
"isRemoteSession": false,
"windowStation": "Console",
"note": "RDP session-transition behavior is not measurable here (physical console session); it closes at sub-phase 2.1 runner registration"
"note": "RDP session-transition behavior is not measurable here (physical console session); it closes at sub-phase 2.12 runner registration"
},
"displays": [
{
@ -42,7 +42,7 @@
"scaleActuallyApplied": false,
"awareArm": {
"mode": "aware",
"processId": 8764,
"processId": 6368,
"awarenessAtStartup": "PROCESS_DPI_UNAWARE",
"setProcessDpiAwarenessContextPerMonitorV2": "succeeded",
"awarenessEffective": "PROCESS_PER_MONITOR_DPI_AWARE",
@ -74,7 +74,7 @@
},
"unawareArm": {
"mode": "unaware",
"processId": 304,
"processId": 8296,
"awarenessAtStartup": "PROCESS_DPI_UNAWARE",
"setProcessDpiAwarenessContextPerMonitorV2": "not attempted (unaware arm, forced with __COMPAT_LAYER=DPIUNAWARE)",
"awarenessEffective": "PROCESS_DPI_UNAWARE",
@ -130,7 +130,7 @@
"scaleActuallyApplied": false,
"awareArm": {
"mode": "aware",
"processId": 8412,
"processId": 6664,
"awarenessAtStartup": "PROCESS_DPI_UNAWARE",
"setProcessDpiAwarenessContextPerMonitorV2": "succeeded",
"awarenessEffective": "PROCESS_PER_MONITOR_DPI_AWARE",
@ -162,7 +162,7 @@
},
"unawareArm": {
"mode": "unaware",
"processId": 5060,
"processId": 5132,
"awarenessAtStartup": "PROCESS_DPI_UNAWARE",
"setProcessDpiAwarenessContextPerMonitorV2": "not attempted (unaware arm, forced with __COMPAT_LAYER=DPIUNAWARE)",
"awarenessEffective": "PROCESS_DPI_UNAWARE",

View file

@ -1,6 +1,6 @@
{
"Probe": "13-ledger-check",
"CapturedAtUtc": "2026-07-27T03:13:25Z",
"CapturedAtUtc": "2026-07-27T06:02:07Z",
"Question": "does FINDINGS.md satisfy R5, R6 and R7 as a machine-checkable contract",
"Ledger": "probes/windows/FINDINGS.md",
"RowCount": 79,
@ -61,7 +61,7 @@
{
"Id": "A10-2",
"Area": "10",
"Closure": "2.1"
"Closure": "2.12"
},
{
"Id": "A10-3",
@ -84,8 +84,8 @@
"Closure": "2.12"
}
],
"MeasuredHunkCount": 37,
"HunkIndexRowCount": 37,
"MeasuredHunkCount": 43,
"HunkIndexRowCount": 43,
"ContradictsRowCount": 5,
"ContradictsRowsMapped": 5,
"BijectionHolds": true,

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

@ -88,6 +88,24 @@ fn ci_compares_the_release_binary_to_the_workspace_version() {
assert!(workflow.contains("[ \"$ACTUAL_OUTPUT\" != \"$EXPECTED_OUTPUT\" ]"));
}
#[test]
fn ci_windows_lane_gates_the_full_package_surface() {
let workflow = include_str!("../../.github/workflows/ci.yml").replace("\r\n", "\n");
assert!(workflow.contains(
"cargo clippy --locked -p agent-desktop-core -p agent-desktop-windows \
-p agent-desktop -p agent-desktop-ffi --all-targets -- -D warnings"
));
assert!(workflow.contains("--edges', 'normal,build,dev"));
assert!(workflow.contains("run: cargo test --locked -p agent-desktop\n"));
assert!(workflow.contains("run: cargo test --locked -p agent-desktop-ffi --tests"));
assert!(workflow.contains("expected exactly 2 windows cfg shims"));
assert!(workflow.contains("Get-Item target/release/agent-desktop.exe"));
assert!(workflow.contains("ORIGINAL_USERPROFILE=$env:USERPROFILE"));
assert!(workflow.contains("Guard profile isolation"));
assert!(workflow.contains("FAIL: HOME is not under RUNNER_TEMP"));
}
const ADAPTER_PASSTHROUGH_COMMANDS: &[&str] = &[
"clipboard-clear",
"clipboard-get",

View file

@ -53,6 +53,11 @@ fn run_permission_prompt_helper() -> Option<ExitCode> {
}
fn run() -> ExitCode {
#[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) => {
@ -187,6 +192,11 @@ fn validate_wait_for_command(cmd: &Commands, wait: &WaitSelector) -> Result<(),
}
fn run_with_adapter(cmd: Commands, cmd_name: &str, context: &CommandContext) -> ExitCode {
#[cfg(target_os = "windows")]
if let Err(bootstrap_error) = agent_desktop_windows::ensure_owned_process_mta_and_dpi() {
return finish(cmd_name, Err(pre_dispatch_error(bootstrap_error.into())));
}
let adapter = build_adapter();
let adapter: &dyn agent_desktop_core::PlatformAdapter = &adapter;
let report = if command_policy::requires_permission_report(&cmd) {

View file

@ -1,23 +1,9 @@
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::process::Command;
fn agent_desktop_bin() -> PathBuf {
let mut path = std::env::current_exe().expect("test executable path must be available");
path.pop();
path.pop();
path.push("agent-desktop");
assert!(
path.is_file(),
"agent-desktop test binary is missing at {}; build the binary before running tests",
path.display()
);
path
}
fn run(args: &[&str]) -> serde_json::Value {
let output = Command::new(agent_desktop_bin())
let output = Command::new(env!("CARGO_BIN_EXE_agent-desktop"))
.args(args)
.output()
.expect("failed to run agent-desktop");

View file

@ -0,0 +1,141 @@
#![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 entries_under(root: &Path) -> Vec<PathBuf> {
let mut entries = Vec::new();
let mut pending = vec![root.to_path_buf()];
while let Some(directory) = pending.pop() {
let Ok(read) = std::fs::read_dir(&directory) else {
continue;
};
for entry in read.flatten() {
let path = entry.path();
if entry.file_type().is_ok_and(|file_type| file_type.is_dir()) {
pending.push(path.clone());
}
entries.push(path);
}
}
entries
}
#[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 = entries_under(&target);
assert!(
leaked.is_empty(),
"no session artifact — file or directory — 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"
);
}