Add service-managed package registration for Windows sandbox accounts (#45542)

## What changed

- Add an explicit `registered_core` provisioning mode that registers the app package for both managed sandbox accounts and records their runner aliases. Authenticate callers against the service's package family and installed version.
- Persist runtime ownership and publish readiness only after both registrations and AppData metadata grants succeed. Prevent legacy setup or another owner from replacing registered sandbox accounts, and restrict `refresh_only` requests to an existing ready setup.
- Scope service and pipe names by package identity.
- Preserve registrations across package updates and clean them up when the owner uninstalls the app. Fence provisioning during teardown and defer package removal until native cleanup succeeds and the service exits.

## Testing

Add tests for registration opt-in, ownership and readiness checks, legacy setup isolation, metadata ACL preservation, listener shutdown handling, and the cleanup finalizer's commit barrier and UTF-8 input.

GitOrigin-RevId: 69e52f7463285b51f3bef831f071245f0eca0838
This commit is contained in:
zm-oai
2026-09-14 23:24:10 +00:00
committed by copyberry
parent 18d7ace221
commit 6ae5e71458
50 changed files with 3515 additions and 169 deletions
@@ -1,3 +1,4 @@
use crate::SandboxRuntimeAccount;
use crate::dpapi;
use crate::logging::debug_log;
use crate::resolved_permissions::ResolvedWindowsSandboxPermissions;
@@ -20,13 +21,19 @@ use crate::winutil::local_user_flags;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use anyhow::ensure;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use std::collections::HashMap;
use std::fs;
use std::os::windows::io::FromRawHandle;
use std::os::windows::io::OwnedHandle;
use std::path::Path;
use std::path::PathBuf;
use windows_sys::Win32::NetworkManagement::NetManagement::UF_ACCOUNTDISABLE;
use windows_sys::Win32::Security::LOGON32_LOGON_INTERACTIVE;
use windows_sys::Win32::Security::LOGON32_PROVIDER_DEFAULT;
use windows_sys::Win32::Security::LogonUserW;
#[cfg(test)]
#[path = "identity_integration_tests.rs"]
@@ -147,6 +154,54 @@ fn decode_password(record: &SandboxUserRecord) -> Result<String> {
Ok(pwd)
}
/// Opens a token for one existing managed account without provisioning or changing it.
/// The caller must keep the authenticated credential directory pinned and verify
/// the token's recorded SID, group membership, and non-administrator status.
pub fn logon_existing_sandbox_account(
codex_home: &Path,
account: SandboxRuntimeAccount,
) -> Result<OwnedHandle> {
// Unlike the ordinary app-side readers, this service recovery path must
// never create diagnostic files beneath an owner-controlled directory.
let marker: SetupMarker = serde_json::from_slice(
&fs::read(setup_marker_path(codex_home)).context("read sandbox setup marker")?,
)
.context("parse sandbox setup marker")?;
ensure!(
marker.version_matches(),
"sandbox setup marker is missing or incompatible"
);
let users: SandboxUsersFile = serde_json::from_slice(
&fs::read(sandbox_users_path(codex_home)).context("read sandbox accounts")?,
)
.context("parse sandbox accounts")?;
ensure!(users.version_matches(), "sandbox accounts are incompatible");
let record = match account {
SandboxRuntimeAccount::Offline => users.offline,
SandboxRuntimeAccount::Online => users.online,
};
ensure!(
record.username.eq_ignore_ascii_case(account.username()),
"sandbox account record does not match the managed account"
);
let password = crate::to_wide(decode_password(&record)?);
let mut token = 0;
if unsafe {
LogonUserW(
crate::to_wide(account.username()).as_ptr(),
crate::to_wide(".").as_ptr(),
password.as_ptr(),
LOGON32_LOGON_INTERACTIVE,
LOGON32_PROVIDER_DEFAULT,
&mut token,
)
} == 0
{
return Err(std::io::Error::last_os_error()).context("log on existing sandbox account");
}
Ok(unsafe { OwnedHandle::from_raw_handle(token as _) })
}
fn select_identity(
network_identity: SandboxNetworkIdentity,
codex_home: &Path,
@@ -14,30 +14,29 @@ use serde::Serialize;
use windows_sys::Win32::Foundation as foundation;
use windows_sys::Win32::System::Registry as registry;
use crate::runtime_ownership::RuntimeRegistration;
use crate::winutil::to_wide;
// Package updates can replace the service key, so keep this record outside it.
const INSTALLATION_KEY: &str = r"SOFTWARE\OpenAI\Codex\WindowsSandboxService";
const INSTALLATION_VALUE: &str = "ProvisionedInstallation";
pub const INSTALLATION_KEY: &str = r"SOFTWARE\OpenAI\Codex\WindowsSandboxService";
pub const INSTALLATION_VALUE: &str = "ProvisionedInstallation";
const MAX_VALUE_UNITS: usize = 4096;
#[derive(Clone, Deserialize, Serialize)]
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct DesktopInstallation {
pub created_codex_home: bool,
pub cache_home: PathBuf,
}
#[derive(Clone, Deserialize, Serialize)]
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct InstallationRecord {
pub user_sid: String,
pub codex_home: PathBuf,
pub session_id: u32,
#[serde(default)]
pub desktop_installation: Option<DesktopInstallation>,
}
pub fn load() -> Result<Option<InstallationRecord>> {
load_from(INSTALLATION_KEY)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime: Option<RuntimeRegistration>,
}
pub(crate) fn load_from(key: &str) -> Result<Option<InstallationRecord>> {
@@ -75,10 +74,6 @@ pub(crate) fn load_from(key: &str) -> Result<Option<InstallationRecord>> {
Ok(Some(record))
}
pub fn save(record: &InstallationRecord) -> Result<()> {
save_to(INSTALLATION_KEY, record)
}
pub(crate) fn save_to(key: &str, record: &InstallationRecord) -> Result<()> {
let value = to_wide(
serde_json::to_string(record).context("serialize protected sandbox installation record")?,
@@ -104,19 +99,3 @@ pub(crate) fn save_to(key: &str, record: &InstallationRecord) -> Result<()> {
.context("persist protected sandbox installation record")
}
}
pub fn remove() -> Result<()> {
let status = unsafe {
registry::RegDeleteKeyW(
registry::HKEY_LOCAL_MACHINE,
to_wide(INSTALLATION_KEY).as_ptr(),
)
};
match status {
foundation::ERROR_SUCCESS
| foundation::ERROR_FILE_NOT_FOUND
| foundation::ERROR_PATH_NOT_FOUND => Ok(()),
status => Err(io::Error::from_raw_os_error(status as i32))
.context("remove protected sandbox installation record"),
}
}
+51 -6
View File
@@ -95,6 +95,34 @@ mod provisioning_client;
#[cfg(target_os = "windows")]
mod provisioning_protocol;
#[cfg(target_os = "windows")]
mod runtime_ownership;
#[cfg(target_os = "windows")]
mod service_identity;
#[cfg(target_os = "windows")]
#[doc(hidden)]
pub use runtime_ownership::CORE_INSTALLATION_KEY;
#[cfg(target_os = "windows")]
#[doc(hidden)]
pub use runtime_ownership::INSTALLATION_KEY;
#[cfg(target_os = "windows")]
#[doc(hidden)]
pub use runtime_ownership::INSTALLATION_VALUE;
#[cfg(target_os = "windows")]
#[doc(hidden)]
pub use runtime_ownership::RuntimeAccountRegistration;
#[cfg(target_os = "windows")]
#[doc(hidden)]
pub use runtime_ownership::RuntimeRegistration;
#[cfg(target_os = "windows")]
#[doc(hidden)]
pub use runtime_ownership::load_installation;
#[cfg(target_os = "windows")]
#[doc(hidden)]
pub use runtime_ownership::remove_installation;
#[cfg(target_os = "windows")]
#[doc(hidden)]
pub use runtime_ownership::save_installation;
#[cfg(target_os = "windows")]
mod resolved_permissions;
#[cfg(target_os = "windows")]
mod token;
@@ -146,6 +174,8 @@ mod setup_provisioning;
#[cfg(target_os = "windows")]
#[doc(hidden)]
pub use setup_provisioning::main as setup_helper_main;
#[cfg(target_os = "windows")]
pub use setup_provisioning::provision_sandbox_in_process;
#[cfg(target_os = "windows")]
mod spawn_prep;
@@ -171,12 +201,6 @@ pub(crate) use elevated::runner_pipe;
pub use installation_record::DesktopInstallation;
#[cfg(target_os = "windows")]
pub use installation_record::InstallationRecord;
#[cfg(target_os = "windows")]
pub use installation_record::load as load_sandbox_installation;
#[cfg(target_os = "windows")]
pub use installation_record::remove as remove_sandbox_installation;
#[cfg(target_os = "windows")]
pub use installation_record::save as save_sandbox_installation;
#[cfg(target_os = "windows")]
pub use acl::add_deny_read_ace;
@@ -249,6 +273,8 @@ pub use hide_users::hide_current_user_profile_dir;
#[cfg(target_os = "windows")]
pub use hide_users::hide_newly_created_users;
#[cfg(target_os = "windows")]
pub use identity::logon_existing_sandbox_account;
#[cfg(target_os = "windows")]
pub use identity::require_logon_sandbox_creds;
#[cfg(target_os = "windows")]
pub use identity::sandbox_setup_is_complete;
@@ -335,6 +361,8 @@ pub use provisioning_protocol::PROVISIONING_PROTOCOL_VERSION;
#[cfg(target_os = "windows")]
pub use provisioning_protocol::ProvisioningMessage;
#[cfg(target_os = "windows")]
pub use provisioning_protocol::SANDBOX_GROUP_CHANGED;
#[cfg(target_os = "windows")]
pub use provisioning_protocol::SANDBOX_PROVISIONING_PIPE_NAME;
#[cfg(target_os = "windows")]
pub use provisioning_protocol::SandboxProvisioningRequest;
@@ -353,6 +381,15 @@ pub use resolved_permissions::WindowsSandboxTokenMode;
#[cfg(target_os = "windows")]
pub use resolved_permissions::token_mode_for_permission_profile;
#[cfg(target_os = "windows")]
pub use runtime_ownership::APP_CORE_RUNNER_ALIAS;
#[cfg(target_os = "windows")]
pub use runtime_ownership::SandboxRuntimeAccount;
#[cfg(target_os = "windows")]
pub use service_identity::windows_sandbox_service_name;
#[cfg(target_os = "windows")]
pub use service_identity::windows_sandbox_service_pipe_name;
#[cfg(target_os = "windows")]
pub use setup::OFFLINE_USERNAME;
#[cfg(target_os = "windows")]
pub use setup::ONLINE_USERNAME;
@@ -363,6 +400,8 @@ pub use setup::SandboxSetupRequest;
#[cfg(target_os = "windows")]
pub use setup::SetupRootOverrides;
#[cfg(target_os = "windows")]
pub use setup::SetupRuntime;
#[cfg(target_os = "windows")]
pub use setup::run_elevated_provisioning_setup;
#[cfg(target_os = "windows")]
pub use setup::run_elevated_provisioning_setup_with_retained_handles;
@@ -402,6 +441,8 @@ pub use stdio_bridge::forward_sandbox_session_stdio;
#[doc(hidden)]
pub use token::LocalSid;
#[cfg(target_os = "windows")]
pub use token::TokenGroup;
#[cfg(target_os = "windows")]
pub use token::convert_string_sid_to_sid;
#[cfg(target_os = "windows")]
pub use token::create_readonly_token_with_cap_from;
@@ -416,6 +457,8 @@ pub use token::create_workspace_write_token_with_caps_from;
#[cfg(target_os = "windows")]
pub use token::get_current_token_for_restriction;
#[cfg(target_os = "windows")]
pub use token::token_groups;
#[cfg(target_os = "windows")]
pub use token_user::get_user_sid_bytes;
#[cfg(target_os = "windows")]
pub use unified_exec::WindowsSandboxSessionRequest;
@@ -432,6 +475,8 @@ pub use uninstall_windows::clean_up_packaged_windows_sandbox;
#[cfg(target_os = "windows")]
pub use uninstall_windows::prepare_packaged_windows_sandbox_cleanup;
#[cfg(target_os = "windows")]
pub use uninstall_windows::prepare_packaged_windows_sandbox_cleanup_with_retained_tokens;
#[cfg(target_os = "windows")]
pub use wfp::install_wfp_filters_for_account;
#[cfg(target_os = "windows")]
pub use wfp_setup::install_wfp_filters;
@@ -88,6 +88,18 @@ pub fn setup_log_writer(base_dir: &Path) -> Result<File> {
}
}
/// Release setup's retained handles under the setup lock, after its writers finish.
/// Keep the slot initialized so later diagnostics cannot reopen a caller-controlled path.
pub(crate) fn release_setup_log() -> Result<()> {
if let Some(log) = SETUP_LOG.get() {
let mut log = log
.lock()
.map_err(|_| anyhow::anyhow!("setup log lock poisoned"))?;
*log = None;
}
Ok(())
}
fn append_line(line: &str, base_dir: Option<&Path>) {
if let Some(log) = SETUP_LOG.get() {
if let Ok(mut log) = log.lock()
@@ -136,6 +148,54 @@ pub fn log_note(msg: &str, base_dir: Option<&Path>) {
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn setup_log_releases_handles_without_path_fallback() {
const CHILD_ENV: &str = "CODEX_TEST_SETUP_LOG_RELEASE_CHILD";
if std::env::var(CHILD_ENV).ok().as_deref() != Some("1") {
// SETUP_LOG is process-global; isolate this test from ordinary daily logging.
let output = std::process::Command::new(std::env::current_exe().expect("test binary"))
.args([
"--exact",
"logging::tests::setup_log_releases_handles_without_path_fallback",
"--nocapture",
])
.env(CHILD_ENV, "1")
.output()
.expect("run isolated setup-log test");
assert!(output.status.success(), "isolated test failed: {output:?}");
assert!(
String::from_utf8_lossy(&output.stdout).contains("setup-log release verified"),
"isolated test did not run: {output:?}"
);
return;
}
let tempdir = tempfile::tempdir().expect("tempdir");
let directory = tempdir.path().join(".sandbox");
std::fs::create_dir(&directory).expect("create sandbox directory");
drop(setup_log_writer(&directory).expect("open retained setup log"));
let error =
std::fs::remove_dir_all(&directory).expect_err("setup handles prevent deletion");
assert_eq!(
error.raw_os_error(),
Some(windows_sys::Win32::Foundation::ERROR_SHARING_VIOLATION as i32)
);
release_setup_log().expect("release setup log");
std::fs::remove_dir_all(&directory).expect("delete sandbox after releasing handles");
std::fs::create_dir(&directory).expect("recreate caller-controlled directory");
release_setup_log().expect("repeated release is harmless");
log_note("must not reopen the directory", Some(&directory));
assert!(
std::fs::read_dir(&directory)
.expect("read directory")
.next()
.is_none()
);
println!("setup-log release verified");
}
#[test]
fn preview_does_not_panic_on_utf8_boundary() {
@@ -32,9 +32,10 @@ use windows_sys::Win32::System::Pipes::GetNamedPipeServerProcessId;
use windows_sys::Win32::System::Pipes::WaitNamedPipeW;
use windows_sys::Win32::System::Services;
const SERVICE_NAME: &str = "CodexSandboxService";
const PROVISIONING_TIMEOUT: Duration = Duration::from_secs(120);
mod group_change;
impl WindowsSandboxProvisioningSettings {
/// Derives the full firewall settings using the same environment handling as elevated setup.
pub fn from_environment(
@@ -113,6 +114,8 @@ pub fn provision_windows_sandbox_via_service(
.to_str()
.context("sandbox provisioning home is not valid UTF-8")?
.to_owned(),
registered_core: false,
refresh_only: false,
settings,
listeners,
},
@@ -178,6 +181,12 @@ fn send_service_request(
}
Err(error) => return Err(error),
};
if matches!(&response, crate::SandboxProvisioningResponse::Error { message }
if message == crate::SANDBOX_GROUP_CHANGED)
{
drop(pipe);
return group_change::retry(request, deadline);
}
Ok(response)
}
@@ -211,15 +220,16 @@ fn exchange_request(
}
fn connect(deadline: Instant) -> anyhow::Result<Option<File>> {
let pipe_name = crate::windows_sandbox_service_pipe_name()?;
let open_pipe = || {
OpenOptions::new()
.read(true)
.write(true)
.custom_flags(SECURITY_SQOS_PRESENT | SECURITY_IMPERSONATION)
.open(crate::SANDBOX_PROVISIONING_PIPE_NAME)
.open(&pipe_name)
};
let pipe_name = crate::to_wide(crate::SANDBOX_PROVISIONING_PIPE_NAME);
let pipe_name = crate::to_wide(&pipe_name);
loop {
match open_pipe() {
Ok(pipe) => return Ok(Some(pipe)),
@@ -265,7 +275,7 @@ fn verify_server(pipe: HANDLE) -> anyhow::Result<()> {
}
let manager = ServiceHandle(manager);
let service_name = crate::to_wide(SERVICE_NAME);
let service_name = crate::to_wide(crate::windows_sandbox_service_name()?);
let service = unsafe {
Services::OpenServiceW(
manager.0,
@@ -0,0 +1,25 @@
//! Retry only the service's authenticated refusal before dispatch.
//! A lost response may follow a mutation, so the retry's transport errors stay errors.
use std::time::Duration;
use std::time::Instant;
use crate::FramedProvisioningMessage;
use crate::SandboxProvisioningResponse;
pub(super) fn retry(
request: &FramedProvisioningMessage,
deadline: Instant,
) -> anyhow::Result<SandboxProvisioningResponse> {
let mut pipe = loop {
if let Some(pipe) = super::connect(deadline)? {
break pipe;
}
anyhow::ensure!(
Instant::now() < deadline,
"refreshed sandbox service pipe is unavailable"
);
std::thread::sleep(Duration::from_millis(25));
};
super::exchange_request(&mut pipe, request, deadline)
}
@@ -13,6 +13,9 @@ pub const PROVISIONING_PROTOCOL_VERSION: u8 = 1;
/// Named pipe used by the machine-wide Windows sandbox provisioning service.
pub const SANDBOX_PROVISIONING_PIPE_NAME: &str = r"\\.\pipe\OpenAI.CodexSandbox";
/// Pre-dispatch refusal: the caller may reconnect once to the refreshed pipe.
pub const SANDBOX_GROUP_CHANGED: &str = "sandbox group changed before authentication";
/// Versioned provisioning-service message carried in a length-prefixed JSON frame.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FramedProvisioningMessage {
@@ -42,6 +45,12 @@ pub enum ProvisioningMessage {
#[serde(deny_unknown_fields)]
pub struct SandboxProvisioningRequest {
pub codex_home: String,
/// Routing request; the service independently authenticates its installed caller.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub registered_core: bool,
/// Refresh an existing registration without creating or repairing sandbox accounts.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub refresh_only: bool,
pub settings: WindowsSandboxProvisioningSettings,
pub listeners: WindowsSandboxProxyListeners,
}
@@ -0,0 +1,208 @@
//! Shares the protected installation record with mutating sandbox setup.
//! Callers must hold the sandbox setup mutex while admitting or changing ownership.
use std::io;
use anyhow::Context;
use anyhow::Result;
use anyhow::ensure;
use serde::Deserialize;
use serde::Serialize;
use windows_sys::Win32::Foundation as foundation;
use windows_sys::Win32::System::Registry as registry;
/// Selects legacy helper materialization or verified app-contained Core for setup.
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SetupRuntime {
#[default]
Legacy,
Registered,
}
impl SetupRuntime {
#[expect(
clippy::trivially_copy_pass_by_ref,
reason = "serde requires a borrowed field"
)]
pub(crate) fn is_legacy(&self) -> bool {
*self == Self::Legacy
}
}
/// Package-scoped execution alias declared for the app's sandbox runner.
pub const APP_CORE_RUNNER_ALIAS: &str = "codex-core-command-runner.exe";
/// The service may register its package only for these managed sandbox accounts.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SandboxRuntimeAccount {
Offline,
Online,
}
impl SandboxRuntimeAccount {
pub fn username(self) -> &'static str {
match self {
Self::Offline => crate::setup::OFFLINE_USERNAME,
Self::Online => crate::setup::ONLINE_USERNAME,
}
}
}
pub use crate::installation_record::INSTALLATION_KEY;
pub use crate::installation_record::INSTALLATION_VALUE;
use crate::installation_record::InstallationRecord;
use crate::winutil::to_wide;
// Old services overwrite the parent value and delete its key on uninstall.
// A child key preserves Core state and makes that old whole-key delete fail.
pub const CORE_INSTALLATION_KEY: &str =
r"SOFTWARE\OpenAI\Codex\WindowsSandboxService\RegisteredCore";
/// Written before registration so a service restart cannot lose cleanup ownership.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct RuntimeRegistration {
pub package_family: String,
pub accounts: Vec<RuntimeAccountRegistration>,
/// Owner-authorized AppData roots, recorded before granting metadata access.
/// Retain former roots through redirection and package updates until cleanup.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub metadata_roots: Vec<std::path::PathBuf>,
/// Committed only after both registrations and metadata grants succeed.
#[serde(default)]
pub ready_package: Option<String>,
/// Fences setup during normal teardown; a restart must not replay it.
/// Old non-null phase journals fail to decode rather than lose their fence.
#[serde(default, alias = "cleanup")]
pub retiring: Option<String>,
}
impl RuntimeRegistration {
pub fn ready_for_package(&self, full_name: &str) -> bool {
self.retiring.is_none()
&& self.ready_package.as_deref() == Some(full_name)
&& self.accounts.len() == 2
&& self.accounts[0].account != self.accounts[1].account
&& self
.accounts
.iter()
.all(|account| account.alias_path.is_some())
}
}
impl InstallationRecord {
pub fn runtime(&self) -> Result<&RuntimeRegistration> {
self.runtime
.as_ref()
.context("registered runtime ownership is missing")
}
pub fn runtime_mut(&mut self) -> Result<&mut RuntimeRegistration> {
self.runtime
.as_mut()
.context("registered runtime ownership is missing")
}
/// Any persisted cleanup has committed to removing this resource generation.
pub fn admit_owner(&self, owner: &InstallationRecord, family: &str) -> Result<()> {
ensure!(
self.user_sid == owner.user_sid
&& self.codex_home == owner.codex_home
&& self.runtime()?.package_family.eq_ignore_ascii_case(family),
"registered sandbox resources belong to a different owner or package"
);
ensure!(
self.runtime()?.retiring.is_none(),
"registered sandbox cleanup must finish before provisioning"
);
Ok(())
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct RuntimeAccountRegistration {
pub account: SandboxRuntimeAccount,
pub user_sid: String,
/// OS-resolved alias written by the service, never inferred from a user name.
#[serde(default)]
pub alias_path: Option<std::path::PathBuf>,
}
/// Core owns its complete record; the old parent is only a fallback before/after Core.
pub fn load_installation() -> Result<Option<InstallationRecord>> {
if let Some(record) = crate::installation_record::load_from(CORE_INSTALLATION_KEY)? {
ensure!(
record.runtime.is_some(),
"Core installation record is missing runtime state"
);
return Ok(Some(record));
}
crate::installation_record::load_from(INSTALLATION_KEY)
}
/// Retires only legacy ownership while the caller holds the setup lock.
/// Registered Core ownership belongs to its post-service-exit finalizer.
pub fn remove_installation() -> Result<()> {
let Some(record) = load_installation()? else {
return Ok(());
};
ensure!(
record.runtime.is_none(),
"registered Core ownership must be retired after service exit"
);
let status = unsafe {
registry::RegDeleteKeyValueW(
registry::HKEY_LOCAL_MACHINE,
to_wide(INSTALLATION_KEY).as_ptr(),
to_wide(INSTALLATION_VALUE).as_ptr(),
)
};
match status {
foundation::ERROR_SUCCESS
| foundation::ERROR_FILE_NOT_FOUND
| foundation::ERROR_PATH_NOT_FOUND => flush_installation(INSTALLATION_KEY),
status => Err(io::Error::from_raw_os_error(status as i32))
.context("remove protected legacy sandbox installation record"),
}
}
/// Persists and flushes ownership before an operation may outlive this service.
/// The caller must hold the sandbox setup mutex across its read/modify/write.
pub fn save_installation(record: &InstallationRecord) -> Result<()> {
let key = if record.runtime.is_some() {
CORE_INSTALLATION_KEY
} else {
INSTALLATION_KEY
};
crate::installation_record::save_to(key, record)?;
flush_installation(key)
}
fn flush_installation(path: &str) -> Result<()> {
let mut key = 0;
let status = unsafe {
registry::RegOpenKeyExW(
registry::HKEY_LOCAL_MACHINE,
to_wide(path).as_ptr(),
0,
registry::KEY_QUERY_VALUE,
&mut key,
)
};
if status != foundation::ERROR_SUCCESS {
return Err(io::Error::from_raw_os_error(status as i32))
.context("open runtime ownership record");
}
let status = unsafe { registry::RegFlushKey(key) };
unsafe { registry::RegCloseKey(key) };
if status != foundation::ERROR_SUCCESS {
return Err(io::Error::from_raw_os_error(status as i32))
.context("flush runtime ownership record");
}
Ok(())
}
#[cfg(test)]
#[path = "runtime_ownership_tests.rs"]
mod tests;
@@ -0,0 +1,241 @@
//! Tests owner admission, readiness receipts, and backward-compatible record decoding.
use super::*;
use crate::DesktopInstallation;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
fn record() -> InstallationRecord {
InstallationRecord {
user_sid: "S-1-5-21-1-2-3-1000".into(),
codex_home: PathBuf::from(r"C:\Users\owner\.codex"),
session_id: 1,
desktop_installation: Some(DesktopInstallation {
created_codex_home: true,
cache_home: PathBuf::from(r"C:\Users\owner\.cache"),
}),
runtime: Some(RuntimeRegistration {
package_family: "OpenAI.Codex_publisher".into(),
metadata_roots: Vec::new(),
ready_package: None,
accounts: vec![RuntimeAccountRegistration {
account: SandboxRuntimeAccount::Offline,
user_sid: "S-1-5-21-1-2-3-1001".into(),
alias_path: None,
}],
retiring: None,
}),
}
}
fn pending() -> InstallationRecord {
let mut record = record();
record.runtime_mut().unwrap().retiring = Some("one-cleanup-generation".into());
record
}
const READY_PACKAGE: &str = "OpenAI.Codex_1.0.0.0_arm64__publisher";
fn ready_runtime() -> RuntimeRegistration {
let mut runtime = record().runtime.unwrap();
runtime.ready_package = Some(READY_PACKAGE.into());
runtime.accounts[0].alias_path = Some(PathBuf::from(r"C:\aliases\offline.exe"));
runtime.accounts.push(RuntimeAccountRegistration {
account: SandboxRuntimeAccount::Online,
user_sid: "S-1-5-21-1-2-3-1002".into(),
alias_path: Some(PathBuf::from(r"C:\aliases\online.exe")),
});
runtime
}
#[test]
fn readiness_requires_a_receipt_for_the_current_package_version() {
let mut runtime = ready_runtime();
assert!(runtime.ready_for_package(READY_PACKAGE));
assert!(!runtime.ready_for_package("OpenAI.Codex_2.0.0.0_arm64__publisher"));
runtime.ready_package = None;
assert!(!runtime.ready_for_package(READY_PACKAGE));
}
#[test]
fn readiness_requires_both_distinct_account_receipts() {
let mut missing_account = ready_runtime();
missing_account.accounts.pop();
let mut missing_alias = ready_runtime();
missing_alias.accounts[1].alias_path = None;
let mut duplicate_role = ready_runtime();
duplicate_role.accounts[1].account = SandboxRuntimeAccount::Offline;
let mut extra_account = ready_runtime();
extra_account
.accounts
.push(extra_account.accounts[0].clone());
for runtime in [
missing_account,
missing_alias,
duplicate_role,
extra_account,
] {
assert!(!runtime.ready_for_package(READY_PACKAGE));
}
}
#[test]
fn readiness_is_revoked_by_the_retirement_fence() {
let mut runtime = ready_runtime();
runtime.retiring = Some("one-cleanup-generation".into());
assert!(!runtime.ready_for_package(READY_PACKAGE));
}
#[test]
fn admission_accepts_package_family_casing() {
for stored in ["OpenAI.Codex_publisher", "openai.codex_PUBLISHER"] {
let mut record = record();
record.runtime_mut().unwrap().package_family = stored.into();
let expected = record.clone();
for requested in ["OpenAI.Codex_publisher", "OPENAI.CODEX_PUBLISHER"] {
record.admit_owner(&record.clone(), requested).unwrap();
assert_eq!(record, expected);
}
}
}
#[test]
fn admission_rejects_other_owner_home_and_family() {
let record = record();
let expected = record.clone();
for (home, sid, family) in [
(
record.codex_home.as_path(),
"S-1-5-21-1-2-3-2000",
record.runtime().unwrap().package_family.as_str(),
),
(
std::path::Path::new(r"C:\Users\owner\other"),
record.user_sid.as_str(),
record.runtime().unwrap().package_family.as_str(),
),
(
record.codex_home.as_path(),
record.user_sid.as_str(),
"OpenAI.CodexBeta_publisher",
),
(
record.codex_home.as_path(),
record.user_sid.as_str(),
"OpenAI.Codex_otherpublisher",
),
] {
let mut requested = record.clone();
requested.codex_home = home.to_path_buf();
requested.user_sid = sid.to_owned();
assert!(record.admit_owner(&requested, family).is_err());
assert_eq!(record, expected);
}
}
#[test]
fn returned_owner_cannot_cancel_a_persisted_retirement_fence() {
let expected = pending();
let journal = serde_json::to_string(&expected).unwrap();
let restored: InstallationRecord = serde_json::from_str(&journal).unwrap();
let returning_owner = record();
assert!(
restored
.admit_owner(
&returning_owner,
&returning_owner.runtime().unwrap().package_family
)
.is_err()
);
assert_eq!(restored, expected);
}
#[test]
fn journal_round_trip_retains_desktop_ownership_and_retirement_fence() {
for record in [record(), pending()] {
let json = serde_json::to_string(&record).unwrap();
assert_eq!(
serde_json::from_str::<InstallationRecord>(&json).unwrap(),
record
);
}
}
#[test]
fn production_record_without_optional_core_or_desktop_fields_still_loads() {
let mut expected = record();
expected.runtime = None;
expected.desktop_installation = None;
let json = r#"{"user_sid":"S-1-5-21-1-2-3-1000","codex_home":"C:\\Users\\owner\\.codex","session_id":1}"#;
assert_eq!(
serde_json::from_str::<InstallationRecord>(json).unwrap(),
expected
);
}
#[test]
fn metadata_root_history_survives_record_round_trip() {
let mut expected = record();
expected.runtime_mut().unwrap().metadata_roots = vec![
PathBuf::from(r"C:\Users\owner\AppData\Roaming"),
PathBuf::from(r"D:\Redirected\Roaming"),
];
for record in [expected.clone(), {
let mut updated = expected;
updated.runtime_mut().unwrap().ready_package = Some(READY_PACKAGE.into());
updated
}] {
let json = serde_json::to_string(&record).unwrap();
assert_eq!(
serde_json::from_str::<InstallationRecord>(&json).unwrap(),
record
);
}
}
#[test]
fn older_runtime_receipts_default_to_no_recorded_metadata_roots() {
let expected = record();
let json = serde_json::to_value(&expected).unwrap();
assert!(json["runtime"].get("metadata_roots").is_none());
assert_eq!(
serde_json::from_value::<InstallationRecord>(json).unwrap(),
expected
);
}
#[test]
fn legacy_null_cleanup_alias_preserves_the_active_record() {
let expected = record();
let mut json = serde_json::to_value(&expected).unwrap();
let runtime = json["runtime"].as_object_mut().unwrap();
runtime.remove("retiring");
runtime.insert("cleanup".into(), serde_json::Value::Null);
assert_eq!(
serde_json::from_value::<InstallationRecord>(json).unwrap(),
expected
);
}
#[test]
fn legacy_non_null_cleanup_journal_cannot_silently_drop_its_fence() {
for stage in ["native_started", "native_complete"] {
let mut json = serde_json::to_value(record()).unwrap();
let runtime = json["runtime"].as_object_mut().unwrap();
runtime.remove("retiring");
let accounts = runtime["accounts"].clone();
runtime.insert(
"cleanup".into(),
serde_json::json!({
"id": "one-cleanup-generation",
"stage": stage,
"accounts": accounts,
}),
);
assert!(serde_json::from_value::<InstallationRecord>(json).is_err());
}
}
@@ -0,0 +1,67 @@
//! Keep each packaged channel's service and pipe separate while accounts remain shared.
//! An unpackaged client may use a routing hint; service admission still verifies OS identity.
use anyhow::Context;
use anyhow::Result;
use anyhow::ensure;
use windows_sys::Win32::System::Threading::GetCurrentProcess;
use crate::package_identity::process_package_family;
/// SCM name matching the packaged application's manifest. Unpackaged callers keep
/// the legacy service lookup and may use ordinary elevated setup when it is absent.
pub fn windows_sandbox_service_name() -> Result<String> {
let Some(family) = service_package_family()? else {
return Ok("CodexSandboxService".into());
};
let (name, _) = family.rsplit_once('_').context("invalid package family")?;
Ok(format!("CodexSandboxService.{name}"))
}
/// The full family, including publisher identity, qualifies the authenticated pipe.
pub fn windows_sandbox_service_pipe_name() -> Result<String> {
Ok(match service_package_family()? {
Some(family) => format!("{}.{}", crate::SANDBOX_PROVISIONING_PIPE_NAME, family),
None => crate::SANDBOX_PROVISIONING_PIPE_NAME.into(),
})
}
pub(crate) fn current_family() -> Result<Option<String>> {
unsafe { process_package_family(GetCurrentProcess()) }
}
fn service_package_family() -> Result<Option<String>> {
if let Some(family) = current_family()? {
return Ok(Some(family));
}
let Some(hint) = std::env::var_os("CODEX_WINDOWS_SANDBOX_PACKAGE_FAMILY") else {
return Ok(None);
};
let family = hint
.to_str()
.context("service package family is not UTF-8")?;
validate_service_family_hint(family)?;
// Lookup only: SCM/pipe verification and registered-client admission use OS identity.
Ok(Some(family.to_owned()))
}
fn validate_service_family_hint(family: &str) -> Result<()> {
let (name, publisher) = family
.rsplit_once('_')
.context("invalid service package family")?;
ensure!(
!name.is_empty()
&& name.len() <= 50
&& name
.bytes()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'-'))
&& publisher.len() == 13
&& publisher.bytes().all(|c| c.is_ascii_alphanumeric()),
"invalid service package family"
);
Ok(())
}
#[cfg(test)]
#[path = "service_identity_tests.rs"]
mod tests;
@@ -0,0 +1,26 @@
use super::validate_service_family_hint;
#[test]
fn service_routing_accepts_package_families() {
for family in [
"OpenAI.Codex_3k8sg7r9htsxt",
"OpenAI.CodexBeta_jabp31b5fhs74",
] {
assert!(validate_service_family_hint(family).is_ok());
}
}
#[test]
fn service_routing_rejects_paths_and_malformed_families() {
for family in [
"",
"_3k8sg7r9htsxt",
"OpenAI.Codex_bad",
"OpenAI.Codex_3k8sg7r9htsxt\\child",
"..\\OpenAI.Codex_3k8sg7r9htsxt",
"OpenAI.Codex_extra_3k8sg7r9htsxt",
"OpenAI.Codex\0_3k8sg7r9htsxt",
] {
assert!(validate_service_family_hint(family).is_err(), "{family:?}");
}
}
+6
View File
@@ -1,3 +1,4 @@
pub use crate::runtime_ownership::SetupRuntime;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeSet;
@@ -350,6 +351,7 @@ fn run_setup_refresh_inner(
otel: None,
real_user: std::env::var("USERNAME").unwrap_or_else(|_| "Administrators".to_string()),
mode: SetupMode::Full,
runtime: SetupRuntime::Legacy,
refresh_only: true,
};
let json = serde_json::to_vec(&payload)?;
@@ -690,6 +692,8 @@ struct ElevationPayload {
otel: Option<codex_otel::StatsigMetricsSettings>,
real_user: String,
mode: SetupMode,
#[serde(default, skip_serializing_if = "SetupRuntime::is_legacy")]
runtime: SetupRuntime,
#[serde(default)]
refresh_only: bool,
}
@@ -1096,6 +1100,7 @@ fn elevated_provisioning_payload(
real_user: std::env::var("USERNAME").unwrap_or_else(|_| "Administrators".to_string()),
otel: codex_otel::global_statsig_metrics_settings(),
mode: SetupMode::InteractiveProvision,
runtime: SetupRuntime::Legacy,
refresh_only: false,
}
}
@@ -1167,6 +1172,7 @@ pub fn run_elevated_provisioning_setup_with_retained_handles(
otel: codex_otel::global_statsig_metrics_settings(),
real_user: real_user.to_string(),
mode: SetupMode::ProvisionOnly,
runtime: SetupRuntime::Legacy,
refresh_only: false,
};
run_setup_exe(
@@ -1,7 +1,10 @@
//! Implements the Windows setup helper behind its shared library entrypoint.
//! Payload validation and all provisioning modes retain the helper contract.
//! Privileged sandbox setup shared by the setup helper and the service's ProvisionOnly path.
//! Full and read-ACL setup remain helper entrypoints; only they may launch a read-ACL child.
//! Command-line account provisioning cannot enter the service's admitted Core path.
mod firewall;
mod service;
pub use service::provision_sandbox_in_process;
mod read_acl_mutex;
use crate::DirectoryOpenDisposition;
@@ -9,6 +12,7 @@ use crate::SETUP_VERSION;
use crate::SetupErrorCode;
use crate::SetupErrorReport;
use crate::SetupFailure;
use crate::SetupRuntime;
use crate::acquire_sandbox_setup_lock;
use crate::add_deny_write_ace;
use crate::convert_string_sid_to_sid;
@@ -99,6 +103,7 @@ use sandbox_users::provision_sandbox_users;
use sandbox_users::resolve_sandbox_users_group_sid;
use sandbox_users::sid_bytes_to_psid;
// Legacy omits runtime; readers default its absence to Legacy.
#[derive(Debug, Clone, Deserialize, Serialize)]
struct Payload {
version: u32,
@@ -120,6 +125,8 @@ struct Payload {
real_user: String,
#[serde(default)]
mode: SetupMode,
#[serde(default, skip_serializing_if = "SetupRuntime::is_legacy")]
runtime: SetupRuntime,
#[serde(default)]
refresh_only: bool,
}
@@ -134,6 +141,16 @@ enum SetupMode {
ReadAclsOnly,
}
impl SetupMode {
fn provisions_accounts(self, refresh_only: bool) -> bool {
match self {
Self::ProvisionOnly | Self::InteractiveProvision => true,
Self::Full => !refresh_only,
Self::ReadAclsOnly => false,
}
}
}
#[derive(Clone, Copy)]
enum DaclInheritance {
Inherited,
@@ -519,6 +536,24 @@ fn real_main(setup_mode: &mut Option<SetupMode>) -> Result<()> {
),
)));
}
// Only this entry parses command-line JSON. Keep admission locked through
// the entire account-provisioning operation, including initial Full setup.
// The in-process adapter enters separately after authenticated service admission.
let _setup_guard = if payload.mode.provisions_accounts(payload.refresh_only) {
let guard = acquire_sandbox_setup_lock(INFINITE)?;
anyhow::ensure!(
payload.runtime == SetupRuntime::Legacy,
"registered Core requires service-owned provisioning"
);
anyhow::ensure!(
crate::runtime_ownership::load_installation()?
.is_none_or(|record| record.runtime.is_none()),
"registered Core owns these sandbox accounts; helper provisioning is not permitted"
);
Some(guard)
} else {
None
};
run_payload(&payload)
}
@@ -817,6 +852,9 @@ fn lock_persistent_sandbox_dirs(payload: &Payload, sandbox_group_sid: &[u8]) ->
}
fn lock_sandbox_bin_dir(payload: &Payload, sandbox_group_sid: &[u8]) -> Result<()> {
if payload.runtime == SetupRuntime::Registered {
return Ok(());
}
// The owner's unelevated refresh must be able to reapply this protected DACL.
lock_sandbox_dir(
&sandbox_bin_dir(&payload.codex_home),
@@ -1128,6 +1166,10 @@ fn run_setup_full(payload: &Payload, log: &mut dyn Write, sbx_dir: &Path) -> Res
#[path = "setup_provisioning/acl_tests.rs"]
mod acl_tests;
#[cfg(test)]
#[path = "setup_provisioning/registered_tests.rs"]
mod registered_tests;
#[cfg(test)]
mod tests {
use super::Payload;
@@ -1149,7 +1191,7 @@ mod tests {
use windows_sys::Win32::Foundation::LocalFree;
use windows_sys::Win32::Storage::FileSystem::FILE_DELETE_CHILD;
fn payload_json() -> serde_json::Value {
pub(super) fn payload_json() -> serde_json::Value {
json!({
"version": SETUP_VERSION,
"offline_username": "CodexSandboxOffline",
@@ -7,6 +7,7 @@ use super::GRANT_ACCESS;
use super::Payload;
use super::SETUP_VERSION;
use super::SetupMode;
use super::SetupRuntime;
use super::convert_string_sid_to_sid;
use super::lock_sandbox_bin_dir;
use super::lock_sandbox_dir;
@@ -89,6 +90,7 @@ fn lock_sandbox_dir_blocks_inherited_write_for_runner_files() {
otel: None,
real_user,
mode: setup_mode,
runtime: SetupRuntime::Legacy,
refresh_only: false,
};
lock_sandbox_bin_dir(&payload, &sandbox_group_sid).expect("lock sandbox bin");
@@ -1,6 +1,7 @@
use anyhow::Result;
use std::io::Write;
use windows::Win32::Foundation::RPC_E_CHANGED_MODE;
use windows::Win32::Foundation::S_OK;
use windows::Win32::Foundation::VARIANT_TRUE;
use windows::Win32::NetworkManagement::WindowsFirewall::INetFwPolicy2;
@@ -58,25 +59,31 @@ struct BlockRuleSpec<'a> {
remote_ports: Option<&'a str>,
}
// Balance successful COM initialization on every return path.
struct FirewallComApartment;
// Firewall COM also supports the service's existing MTA. Balance only our own initialization.
struct FirewallComApartment {
initialized: bool,
}
impl FirewallComApartment {
fn initialize() -> Result<Self> {
let hr = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
if hr.is_err() {
if hr.is_err() && hr != RPC_E_CHANGED_MODE {
return Err(anyhow::Error::new(SetupFailure::new(
SetupErrorCode::HelperFirewallComInitFailed,
format!("CoInitializeEx failed: {hr:?}"),
)));
}
Ok(Self)
Ok(Self {
initialized: hr.is_ok(),
})
}
}
impl Drop for FirewallComApartment {
fn drop(&mut self) {
unsafe { CoUninitialize() };
if self.initialized {
unsafe { CoUninitialize() };
}
}
}
@@ -490,6 +497,10 @@ fn log_line(log: &mut dyn Write, msg: &str) -> Result<()> {
Ok(())
}
#[cfg(test)]
#[path = "firewall_apartment_tests.rs"]
mod apartment_tests;
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
@@ -0,0 +1,40 @@
//! Verifies firewall COM access preserves a service thread's existing apartment.
use super::CLSCTX_INPROC_SERVER;
use super::CoCreateInstance;
use super::FirewallComApartment;
use super::INetFwPolicy2;
use super::NetFwPolicy2;
use pretty_assertions::assert_eq;
use windows::Win32::Foundation::RPC_E_CHANGED_MODE;
use windows::Win32::System::Com::COINIT_APARTMENTTHREADED;
use windows::Win32::System::Com::COINIT_MULTITHREADED;
use windows::Win32::System::Com::CoInitializeEx;
use windows::Win32::System::Com::CoUninitialize;
#[test]
fn firewall_access_preserves_the_services_existing_mta() {
std::thread::spawn(|| {
let initialized = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) };
assert!(initialized.is_ok(), "initialize MTA: {initialized:?}");
// Open the rules collection without adding, changing, or deleting a rule.
let result = (|| -> anyhow::Result<()> {
let _apartment = FirewallComApartment::initialize()?;
let policy: INetFwPolicy2 =
unsafe { CoCreateInstance(&NetFwPolicy2, None, CLSCTX_INPROC_SERVER)? };
let _rules = unsafe { policy.Rules()? };
Ok(())
})();
let apartment = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
unsafe { CoUninitialize() };
assert_eq!(
apartment, RPC_E_CHANGED_MODE,
"caller's MTA must remain initialized"
);
result.expect("read firewall rules from the existing MTA");
})
.join()
.expect("firewall COM test thread");
}
@@ -0,0 +1,91 @@
//! Registration-specific setup serialization and legacy-materialization boundaries.
use super::Payload;
use super::tests::payload_json;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::fs;
#[test]
fn payload_runtime_wire_normalization_preserves_opt_in() {
let mut payload: Payload = serde_json::from_value(payload_json()).unwrap();
assert_eq!(payload.runtime, super::SetupRuntime::Legacy);
let legacy = serde_json::to_value(&payload).unwrap();
assert!(legacy.get("runtime").is_none());
let mut explicit = legacy.clone();
explicit["runtime"] = serde_json::json!("legacy");
let decoded: Payload = serde_json::from_value(explicit).unwrap();
assert_eq!(serde_json::to_value(decoded).unwrap(), legacy);
payload.runtime = super::SetupRuntime::Registered;
let mut registered = legacy.clone();
registered["runtime"] = serde_json::json!("registered");
assert_eq!(serde_json::to_value(&payload).unwrap(), registered);
let decoded: Payload = serde_json::from_value(registered.clone()).unwrap();
assert_eq!(serde_json::to_value(decoded).unwrap(), registered);
for invalid in [
serde_json::Value::Null,
serde_json::json!(true),
serde_json::json!("unverified"),
] {
let mut malformed = legacy.clone();
malformed["runtime"] = invalid;
assert!(serde_json::from_value::<Payload>(malformed).is_err());
}
}
#[test]
fn registered_payload_never_creates_or_locks_legacy_bin() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut json = payload_json();
json["codex_home"] = json!(tmp.path());
json["runtime"] = json!("registered");
let payload: Payload = serde_json::from_value(json).expect("registered payload");
// An empty SID would fail any ACL operation; Registered must not attempt one.
super::lock_sandbox_bin_dir(&payload, &[]).expect("no legacy bin work");
let bin = super::sandbox_bin_dir(tmp.path());
assert!(!bin.exists());
fs::create_dir(&bin).expect("existing legacy bin");
let sentinel = bin.join("sentinel");
fs::write(&sentinel, b"unchanged").expect("existing content");
super::lock_sandbox_bin_dir(&payload, &[]).expect("leave existing bin alone");
assert_eq!(fs::read(sentinel).expect("read sentinel"), b"unchanged");
}
#[test]
fn unverified_payload_cannot_create_legacy_bin() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut json = payload_json();
json["codex_home"] = json!(tmp.path());
json["runtime"] = json!("unverified");
assert!(serde_json::from_value::<Payload>(json).is_err());
assert!(!super::sandbox_bin_dir(tmp.path()).exists());
}
#[test]
fn helper_account_gate_covers_actual_provisioning_modes() {
for (mode, expected) in [
(super::SetupMode::Full, [true, false]),
(super::SetupMode::InteractiveProvision, [true, true]),
(super::SetupMode::ProvisionOnly, [true, true]),
(super::SetupMode::ReadAclsOnly, [false, false]),
] {
assert_eq!(
[false, true].map(|refresh_only| mode.provisions_accounts(refresh_only)),
expected
);
}
}
#[test]
fn parsed_provision_only_cannot_claim_the_private_service_entry() {
let mut json = payload_json();
json["mode"] = json!("provision-only");
json["runtime"] = json!("registered");
json["refresh_only"] = json!(true);
json["service_admitted"] = json!(true);
let payload: Payload = serde_json::from_value(json).expect("payload");
assert!(payload.mode.provisions_accounts(payload.refresh_only));
assert_ne!(payload.runtime, super::SetupRuntime::Legacy);
}
@@ -0,0 +1,49 @@
//! Adapts authenticated service provisioning to the existing setup transaction.
//! The caller owns admission, directory pins, and the setup mutex.
use super::Payload;
use super::SetupMode;
use super::run_payload;
use crate::SETUP_VERSION;
use crate::SetupRuntime;
use crate::logging::log_note;
use crate::sandbox_dir;
use anyhow::Result;
use std::path::Path;
/// Runs the existing ProvisionOnly setup transaction without launching a helper process.
/// The caller authenticates the owner, retains directory protections, and holds the setup mutex
/// on this thread across registration. Firewall setup preserves the caller's COM apartment.
pub fn provision_sandbox_in_process(
codex_home: &Path,
real_user: &str,
settings: crate::WindowsSandboxProvisioningSettings,
runtime: SetupRuntime,
) -> Result<()> {
let payload = Payload {
version: SETUP_VERSION,
offline_username: crate::setup::OFFLINE_USERNAME.to_string(),
online_username: crate::setup::ONLINE_USERNAME.to_string(),
codex_home: codex_home.to_path_buf(),
command_cwd: codex_home.to_path_buf(),
read_roots: Vec::new(),
write_roots: Vec::new(),
deny_read_paths: Vec::new(),
deny_write_paths: Vec::new(),
proxy_ports: settings.proxy_ports,
allow_local_binding: settings.allow_local_binding,
otel: codex_otel::global_statsig_metrics_settings(),
real_user: real_user.to_string(),
mode: SetupMode::ProvisionOnly,
runtime,
refresh_only: false,
};
run_payload(&payload)?;
if let Err(err) = crate::setup_error::clear_setup_error_report(codex_home) {
log_note(
&format!("setup orchestrator: failed to clear setup_error.json after success: {err}"),
Some(&sandbox_dir(codex_home)),
);
}
Ok(())
}
+2 -2
View File
@@ -193,7 +193,7 @@ pub unsafe fn get_current_token_for_restriction() -> Result<HANDLE> {
/// An owned token group, including attributes such as enabled and deny-only.
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct TokenGroup {
pub struct TokenGroup {
pub sid: Vec<u8>,
pub attributes: u32,
}
@@ -202,7 +202,7 @@ pub(crate) struct TokenGroup {
///
/// # Safety
/// `token` must remain a valid token handle with `TOKEN_QUERY` access during this call.
pub(crate) unsafe fn token_groups(token: HANDLE, max_bytes: u32) -> Result<Vec<TokenGroup>> {
pub unsafe fn token_groups(token: HANDLE, max_bytes: u32) -> Result<Vec<TokenGroup>> {
let mut needed = 0;
GetTokenInformation(token, TokenGroups, std::ptr::null_mut(), 0, &mut needed);
ensure!(
@@ -1,5 +1,6 @@
//! Stops sandbox work under the setup lock before removing its resources and protections.
use std::os::windows::io::BorrowedHandle;
use std::path::Path;
use anyhow::Result;
@@ -11,6 +12,7 @@ use crate::setup::ONLINE_USERNAME;
mod firewall;
mod principals;
mod processes;
mod retained_logons;
/// Removes sandbox resources created for one authenticated packaged installation.
/// Keep a supplied home and its ancestors pinned until `clean_up_desktop` starts.
@@ -23,7 +25,8 @@ pub fn clean_up_packaged_windows_sandbox(
}
/// Holds the setup lock after sandbox accounts are disabled, their processes
/// have exited, and their logon tokens have been released.
/// have exited, and all logon tokens except explicitly retained private cleanup
/// tokens have been released.
///
/// Keep this guard on the thread that acquired the setup lock. Callers must not
/// re-enable the accounts or create new sandbox logons before `finish`.
@@ -33,19 +36,34 @@ pub fn clean_up_packaged_windows_sandbox(
#[must_use = "finish cleanup or leave the sandbox accounts disabled with their protections intact"]
pub struct PreparedWindowsSandboxCleanup {
_setup_lock: crate::setup_mutex::SandboxSetupLock,
users: principals::DisabledSandboxUsers,
_retained_logons: retained_logons::RetainedLogons,
}
/// Disables sandbox accounts, stops their processes, and waits for their logon
/// tokens to be released.
/// Disables sandbox accounts and waits for their processes and logon tokens to exit.
///
/// No resources or protections are removed here. On failure, restoration of the
/// original account flags is attempted before the setup lock is released; any
/// restoration failure is included in the returned error.
pub fn prepare_packaged_windows_sandbox_cleanup() -> Result<PreparedWindowsSandboxCleanup> {
prepare_packaged_windows_sandbox_cleanup_with_retained_tokens(&[])
}
/// Retains only the trusted runtime finalizer's login tokens while stopping sandbox work.
/// These must be fresh, private logins used only by that SYSTEM cleanup process, never
/// runner tokens or logins shared with sandbox commands. Ordinary processes are still stopped.
/// The returned guard pins their identities until native cleanup finishes.
pub fn prepare_packaged_windows_sandbox_cleanup_with_retained_tokens(
tokens: &[BorrowedHandle<'_>],
) -> Result<PreparedWindowsSandboxCleanup> {
let setup_lock = crate::setup_mutex::acquire_sandbox_setup_lock(/*timeout_ms*/ 5_000)?;
let mut errors = Vec::new();
let mut users = principals::DisabledSandboxUsers::default();
if let Err(error) = users.disable().and_then(|()| processes::stop(&users)) {
let mut retained = retained_logons::RetainedLogons::default();
if let Err(error) = users.disable().and_then(|()| {
retained = retained_logons::RetainedLogons::capture(tokens, &users)?;
processes::stop(&users, &retained)
}) {
errors.push(format!("{error:#}"));
// No network protections have been removed, so failed preparation can restore these flags.
if let Err(error) = users.restore() {
@@ -56,6 +74,8 @@ pub fn prepare_packaged_windows_sandbox_cleanup() -> Result<PreparedWindowsSandb
Ok(PreparedWindowsSandboxCleanup {
_setup_lock: setup_lock,
users,
_retained_logons: retained,
})
}
@@ -64,16 +84,20 @@ impl PreparedWindowsSandboxCleanup {
///
/// Keep a supplied home and its ancestors pinned until `clean_up_desktop`
/// starts. Independent cleanup steps continue after an error, as in
/// `clean_up_packaged_windows_sandbox`.
/// `clean_up_packaged_windows_sandbox`. Retrying keeps the original account
/// identities; it must not adopt replacement accounts created between attempts.
pub fn finish(
self,
&self,
codex_home: Option<&Path>,
clean_up_desktop: impl FnOnce() -> Result<()>,
) -> Result<()> {
let _prepared = self;
self.users.validate_current()?;
let mut errors = Vec::new();
if let Some(codex_home) = codex_home {
if let Err(error) = crate::logging::release_setup_log() {
errors.push(format!("release setup log: {error:#}"));
}
for directory in [
crate::setup::sandbox_dir(codex_home),
crate::setup::sandbox_secrets_dir(codex_home),
@@ -5,6 +5,7 @@ use std::ptr::null;
use anyhow::Result;
use anyhow::bail;
use anyhow::ensure;
use windows_sys::Win32::NetworkManagement::NetManagement as network;
use crate::setup::OFFLINE_USERNAME;
@@ -47,6 +48,21 @@ impl DisabledSandboxUsers {
self.users.iter().map(|user| user.sid.as_slice())
}
pub(super) fn validate_current(&self) -> Result<()> {
for name in [OFFLINE_USERNAME, ONLINE_USERNAME] {
let Some(flags) = local_user_flags(name)? else {
continue;
};
let captured = self.users.iter().find(|user| user.name == name);
ensure!(
captured.is_some_and(|user| resolve_sid(name).is_ok_and(|sid| sid == user.sid))
&& flags & network::UF_ACCOUNTDISABLE != 0,
"sandbox account changed after being disabled: {name}"
);
}
Ok(())
}
pub(super) fn restore(&self) -> Result<()> {
let mut errors = Vec::new();
for user in &self.users {
@@ -66,6 +82,20 @@ impl DisabledSandboxUsers {
}
}
#[cfg(test)]
impl DisabledSandboxUsers {
// In-memory admission fixture only; this does not resolve or change a local account.
pub(super) fn for_token_test(sid: Vec<u8>) -> Self {
Self {
users: vec![SandboxUser {
name: OFFLINE_USERNAME,
original_flags: network::UF_ACCOUNTDISABLE,
sid,
}],
}
}
}
pub(super) fn remove_sandbox_principal(name: &str) -> Result<()> {
let name_wide = to_wide(name);
let status = if name == "CodexSandboxUsers" {
@@ -1,4 +1,4 @@
//! Stops sandbox-account processes and waits for their logon tokens to be released.
//! Stops sandbox processes and drains logins except exact, pinned cleanup-owned tokens.
use std::ffi::c_void;
use std::io;
@@ -39,9 +39,10 @@ use windows_sys::Win32::System::Threading::TerminateProcess;
use windows_sys::Win32::System::Threading::WaitForSingleObject;
use super::principals::DisabledSandboxUsers;
use super::retained_logons::RetainedLogons;
use crate::token::get_user_sid_bytes;
pub(super) fn stop(users: &DisabledSandboxUsers) -> Result<()> {
pub(super) fn stop(users: &DisabledSandboxUsers, retained: &RetainedLogons) -> Result<()> {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let processes = sandbox_processes(users)?;
@@ -80,8 +81,9 @@ pub(super) fn stop(users: &DisabledSandboxUsers) -> Result<()> {
drop(processes);
// A token can outlive its process or exist before a runner starts. Account disable does
// not revoke it, so release our handles and check for tokens before removing protections.
if !has_sandbox_logon_session(users)? {
// not revoke it, so check for remaining logins before removing protections. Only
// explicitly pinned, private SYSTEM-finalizer tokens may remain; no process is exempt.
if !has_sandbox_logon_session(users, retained)? {
return Ok(());
}
if Instant::now() >= deadline {
@@ -157,7 +159,10 @@ fn sandbox_processes(users: &DisabledSandboxUsers) -> Result<Vec<OwnedHandle>> {
Ok(handles)
}
fn has_sandbox_logon_session(users: &DisabledSandboxUsers) -> Result<bool> {
fn has_sandbox_logon_session(
users: &DisabledSandboxUsers,
retained: &RetainedLogons,
) -> Result<bool> {
let mut count = 0;
let mut logons = null_mut();
let status = unsafe { LsaEnumerateLogonSessions(&mut count, &mut logons) };
@@ -189,7 +194,7 @@ fn has_sandbox_logon_session(users: &DisabledSandboxUsers) -> Result<bool> {
let data = unsafe { &*data.0.cast::<SECURITY_LOGON_SESSION_DATA>() };
// LSA also lists sessions without a user SID, even before sandbox accounts exist.
// Only sessions identified as sandbox users are evidence of remaining sandbox tokens.
if is_sandbox_user(users, data.Sid) {
if is_sandbox_user(users, data.Sid) && !retained.contains(*logon) {
return Ok(true);
}
}
@@ -0,0 +1,77 @@
//! Pins only cleanup-owned login sessions; every other sandbox login still blocks cleanup.
use std::io;
use std::os::windows::io::AsRawHandle;
use std::os::windows::io::BorrowedHandle;
use std::os::windows::io::OwnedHandle;
use anyhow::Context;
use anyhow::Result;
use anyhow::ensure;
use windows_sys::Win32::Foundation::HANDLE;
use windows_sys::Win32::Foundation::LUID;
use windows_sys::Win32::Security::GetTokenInformation;
use windows_sys::Win32::Security::TOKEN_STATISTICS;
use windows_sys::Win32::Security::TokenStatistics;
use super::principals::DisabledSandboxUsers;
#[derive(Default)]
pub(super) struct RetainedLogons {
// Keeping each token open prevents its AuthenticationId from being recycled.
_tokens: Vec<OwnedHandle>,
ids: Vec<LUID>,
}
impl RetainedLogons {
pub(super) fn capture(
tokens: &[BorrowedHandle<'_>],
users: &DisabledSandboxUsers,
) -> Result<Self> {
ensure!(tokens.len() <= 2, "too many retained cleanup tokens");
let mut retained = Self::default();
let mut seen_sids = Vec::new();
for token in tokens {
let token = token.try_clone_to_owned()?;
let handle = token.as_raw_handle() as HANDLE;
let sid = unsafe { crate::token::get_user_sid_bytes(handle) }?;
ensure!(
users.sids().any(|candidate| candidate == sid.as_slice())
&& !seen_sids.contains(&sid),
"cleanup token does not identify a distinct managed sandbox account"
);
let mut stats: TOKEN_STATISTICS = unsafe { std::mem::zeroed() };
let mut returned = 0;
if unsafe {
GetTokenInformation(
handle,
TokenStatistics,
(&mut stats as *mut TOKEN_STATISTICS).cast(),
std::mem::size_of::<TOKEN_STATISTICS>() as u32,
&mut returned,
)
} == 0
{
return Err(io::Error::last_os_error()).context("identify retained cleanup login");
}
ensure!(
returned as usize == std::mem::size_of::<TOKEN_STATISTICS>(),
"unexpected cleanup token statistics length"
);
seen_sids.push(sid);
retained.ids.push(stats.AuthenticationId);
retained._tokens.push(token);
}
Ok(retained)
}
pub(super) fn contains(&self, id: LUID) -> bool {
self.ids
.iter()
.any(|held| held.LowPart == id.LowPart && held.HighPart == id.HighPart)
}
}
#[cfg(test)]
#[path = "retained_logons_tests.rs"]
mod tests;
@@ -0,0 +1,106 @@
//! Exercises retained-logon admission and ownership using read-only current-process tokens.
use super::super::principals::DisabledSandboxUsers;
use super::RetainedLogons;
use crate::token::get_user_sid_bytes;
use anyhow::Result;
use anyhow::ensure;
use pretty_assertions::assert_eq;
use std::os::windows::io::AsHandle;
use std::os::windows::io::AsRawHandle;
use std::os::windows::io::FromRawHandle;
use std::os::windows::io::OwnedHandle;
use windows_sys::Win32::Foundation::HANDLE;
use windows_sys::Win32::Foundation::LUID;
use windows_sys::Win32::Security::TOKEN_QUERY;
use windows_sys::Win32::System::Threading::GetCurrentProcess;
use windows_sys::Win32::System::Threading::OpenProcessToken;
fn current_process_token() -> Result<OwnedHandle> {
let mut raw = 0;
ensure!(
unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut raw) } != 0,
"open current-process token: {}",
std::io::Error::last_os_error()
);
Ok(unsafe { OwnedHandle::from_raw_handle(raw as _) })
}
#[test]
fn matches_only_the_complete_retained_authentication_id() {
let retained = RetainedLogons {
_tokens: Vec::new(),
ids: vec![LUID {
LowPart: 17,
HighPart: 4,
}],
};
let observed = [
LUID {
LowPart: 17,
HighPart: 4,
},
LUID {
LowPart: 18,
HighPart: 4,
},
LUID {
LowPart: 17,
HighPart: 5,
},
LUID {
LowPart: 0,
HighPart: 0,
},
]
.map(|id| retained.contains(id));
assert_eq!(observed, [true, false, false, false]);
assert!(!RetainedLogons::default().contains(LUID {
LowPart: 17,
HighPart: 4
}));
}
#[test]
fn rejects_more_than_two_tokens_before_admission() -> Result<()> {
let token = current_process_token()?;
let error = RetainedLogons::capture(&[token.as_handle(); 3], &DisabledSandboxUsers::default())
.err()
.expect("three token inputs must be rejected");
assert_eq!(error.to_string(), "too many retained cleanup tokens");
Ok(())
}
#[test]
fn rejects_a_token_without_a_captured_managed_sid() -> Result<()> {
let token = current_process_token()?;
assert!(
RetainedLogons::capture(&[token.as_handle()], &DisabledSandboxUsers::default()).is_err()
);
Ok(())
}
#[test]
fn rejects_distinct_handles_for_the_same_managed_sid() -> Result<()> {
let token = current_process_token()?;
let sid = unsafe { get_user_sid_bytes(token.as_raw_handle() as HANDLE) }?;
let users = DisabledSandboxUsers::for_token_test(sid);
let duplicate = token.as_handle().try_clone_to_owned()?;
RetainedLogons::capture(&[token.as_handle()], &users)?;
assert!(RetainedLogons::capture(&[token.as_handle(), duplicate.as_handle()], &users).is_err());
Ok(())
}
#[test]
fn retained_token_remains_queryable_after_the_borrowed_handle_is_closed() -> Result<()> {
let (retained, expected_sid) = {
let token = current_process_token()?;
let sid = unsafe { get_user_sid_bytes(token.as_raw_handle() as HANDLE) }?;
let users = DisabledSandboxUsers::for_token_test(sid.clone());
(RetainedLogons::capture(&[token.as_handle()], &users)?, sid)
};
assert_eq!(retained._tokens.len(), 1);
let actual_sid = unsafe { get_user_sid_bytes(retained._tokens[0].as_raw_handle() as HANDLE) }?;
assert_eq!(actual_sid, expected_sid);
Ok(())
}
@@ -3,5 +3,6 @@ load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "windows-sandbox-service",
binary_test_target_compatible_with = ["@platforms//os:windows"],
compile_data = ["src/registered_runtime/removal.ps1"],
crate_name = "codex_windows_sandbox_service",
)
+4 -4
View File
@@ -16,10 +16,6 @@ path = "src/main.rs"
[lints]
workspace = true
# Temporary until #8415 adds the cleanup implementation that uses these dependencies.
[package.metadata.cargo-shear]
ignored = ["base64", "serde_json"]
[dependencies]
anyhow = { workspace = true }
base64 = { workspace = true }
@@ -38,6 +34,8 @@ pretty_assertions = { workspace = true }
version = "0.58"
features = [
"ApplicationModel",
"Foundation_Collections",
"Management_Deployment",
"Win32_System_WinRT",
]
@@ -54,12 +52,14 @@ features = [
"Win32_System_Environment",
"Win32_System_EventLog",
"Win32_System_IO",
"Win32_System_JobObjects",
"Win32_System_Memory",
"Win32_System_Pipes",
"Win32_System_Registry",
"Win32_System_RemoteDesktop",
"Win32_System_Services",
"Win32_System_SystemServices",
"Win32_System_SystemInformation",
"Win32_System_Threading",
"Win32_UI_Shell",
]
@@ -9,11 +9,16 @@ use std::ptr;
use anyhow::Context;
use anyhow::Result;
pub(crate) use codex_windows_sandbox::CORE_INSTALLATION_KEY;
pub(crate) use codex_windows_sandbox::DesktopInstallation;
pub(crate) use codex_windows_sandbox::INSTALLATION_KEY;
pub(crate) use codex_windows_sandbox::INSTALLATION_VALUE;
pub(crate) use codex_windows_sandbox::InstallationRecord;
pub(crate) use codex_windows_sandbox::load_sandbox_installation as load;
pub(crate) use codex_windows_sandbox::remove_sandbox_installation as remove;
pub(crate) use codex_windows_sandbox::save_sandbox_installation as save;
pub(crate) use codex_windows_sandbox::RuntimeAccountRegistration;
pub(crate) use codex_windows_sandbox::RuntimeRegistration;
pub(crate) use codex_windows_sandbox::load_installation as load;
pub(crate) use codex_windows_sandbox::remove_installation as remove;
pub(crate) use codex_windows_sandbox::save_installation as save_runtime;
use codex_windows_sandbox::validate_local_directory_path;
use windows_sys::Win32::Foundation as foundation;
use windows_sys::Win32::UI::Shell::GetUserProfileDirectoryW;
@@ -40,3 +45,33 @@ pub(crate) fn read_desktop_installation(
cache_home,
})
}
pub(crate) fn load_runtime() -> Result<Option<InstallationRecord>> {
Ok(load()?.filter(|record| record.runtime.is_some()))
}
pub(crate) fn is_current_package_family(record: &InstallationRecord) -> Result<bool> {
Ok(record.runtime()?.package_family.eq_ignore_ascii_case(
&windows::ApplicationModel::Package::Current()?
.Id()?
.FamilyName()?
.to_string(),
))
}
pub(crate) fn save(mut record: InstallationRecord) -> Result<InstallationRecord> {
let _setup_lock = codex_windows_sandbox::acquire_sandbox_setup_lock(/*timeout_ms*/ 5_000)?;
record.runtime = None;
if let Some(current) = load_runtime()? {
let family = windows::ApplicationModel::Package::Current()?
.Id()?
.FamilyName()?;
current.admit_owner(&record, &family.to_string())?;
// The helper wait may have admitted another account. Preserve fresh Core
// state instead of overwriting it with the caller's owner-only snapshot.
record.runtime = current.runtime;
}
save_runtime(&record)?;
Ok(record)
}
+49 -15
View File
@@ -22,8 +22,7 @@ use codex_windows_sandbox::write_provisioning_frame;
pub(crate) use home::OwnedHandle;
pub(crate) use home::pin_directory;
pub(crate) use home::pin_existing_ancestors;
#[cfg(test)]
use request::ProvisioningRequest;
pub(crate) use request::ProvisioningRequest;
pub(crate) use request::ServiceRequest;
use request::validate_request;
use std::mem::size_of;
@@ -40,8 +39,6 @@ use windows_sys::Win32::System::Pipes as pipes;
use crate::installation_record::InstallationRecord;
pub(crate) const PIPE_NAME: &str = codex_windows_sandbox::SANDBOX_PROVISIONING_PIPE_NAME;
const MAX_REQUEST_BYTES: usize = 4096;
const MAX_RESPONSE_MESSAGE_BYTES: usize = 512;
const REQUEST_IDLE_TIMEOUT: Duration = Duration::from_secs(5);
@@ -68,19 +65,31 @@ enum PipeConnection {
pub(crate) fn run(
shutdown: Arc<AtomicBool>,
on_ready: impl FnOnce() -> Result<()>,
register_installation: impl Fn(InstallationRecord, OwnedHandle) -> Result<InstallationRecord>,
on_authenticated_user: impl Fn(
InstallationRecord,
OwnedHandle,
codex_windows_sandbox::SetupRuntime,
) -> Result<InstallationRecord>,
on_session_change: impl Fn() -> Result<()>,
) -> Result<()> {
let listener = listener::ProvisioningListener::open()?;
if shutdown.load(Ordering::Acquire) {
return Ok(());
}
let mut listener = listener::ProvisioningListener::open()?;
on_ready().context("publish provisioning listener readiness")?;
while !shutdown.load(Ordering::Acquire) {
// Refresh before blocking even if a direct setup claim's zero-byte wake was lost.
while refresh_session(&shutdown, &on_session_change)
.context("refresh the recorded sandbox owner")?
{
let connection = accept_pipe_connection(listener.pipe.0)?;
if shutdown.load(Ordering::Acquire) {
// A session refresh can finish cleanup. Never dispatch an already accepted
// connection after it stops the listener, including a disconnected wakeup.
if !refresh_session(&shutdown, &on_session_change)
.context("restore the signed-in user's uninstall listener")?
{
break;
}
// Session-change wakeups close the pipe immediately and can arrive disconnected.
on_session_change().context("restore the signed-in user's uninstall listener")?;
if connection == PipeConnection::Disconnected {
continue;
}
@@ -98,12 +107,14 @@ pub(crate) fn run(
&authorized_process,
&listener.sandbox_sid,
&shutdown,
&register_installation,
&on_authenticated_user,
);
let response = match result {
Ok(response) => response,
Err(error) if error.is::<crate::registered_runtime::RegistrationInterrupted>() => {
return Err(error);
}
Err(error) if error.is::<ServiceUnavailable>() => {
eprintln!("sandbox provisioning service unavailable: {error:#}");
SandboxProvisioningResponse::Unavailable
}
Err(error) => {
@@ -118,10 +129,23 @@ pub(crate) fn run(
message: ProvisioningMessage::ProvisionSandboxResponse { payload: response },
};
write_response(&listener.pipe, &response, &shutdown)?;
unsafe { pipes::DisconnectNamedPipe(listener.pipe.0) };
if shutdown.load(Ordering::Acquire) {
break;
}
listener = listener.refresh()?;
}
Ok(())
}
fn refresh_session(
shutdown: &AtomicBool,
on_session_change: impl FnOnce() -> Result<()>,
) -> Result<bool> {
if shutdown.load(Ordering::Acquire) {
return Ok(false);
}
on_session_change()?;
Ok(!shutdown.load(Ordering::Acquire))
}
/// Sends one frame, then waits briefly for the client to close before disconnecting.
fn write_response(
@@ -234,7 +258,11 @@ fn handle_request(
authorized_process: &crate::package_identity::AuthorizedClientProcess,
sandbox_sid: &[u8],
shutdown: &AtomicBool,
register_installation: &dyn Fn(InstallationRecord, OwnedHandle) -> Result<InstallationRecord>,
on_authenticated_user: &dyn Fn(
InstallationRecord,
OwnedHandle,
codex_windows_sandbox::SetupRuntime,
) -> Result<InstallationRecord>,
) -> Result<SandboxProvisioningResponse> {
let deadline = Instant::now() + REQUEST_IDLE_TIMEOUT;
let mut request = [0_u8; MAX_REQUEST_BYTES];
@@ -316,7 +344,13 @@ fn handle_request(
return Err(error)
.context("requested sandbox settings violate administrator-controlled machine policy");
}
crate::provisioning::run(identity, request, register_installation)
crate::provisioning::run(
identity,
request,
sandbox_sid,
shutdown,
on_authenticated_user,
)
}
fn is_config_parse_error(error: &anyhow::Error) -> bool {
@@ -4,6 +4,7 @@ use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use codex_windows_sandbox::DirectoryOpenDisposition;
use codex_windows_sandbox::SetupRuntime;
use codex_windows_sandbox::create_directory_guard;
use codex_windows_sandbox::string_from_sid_bytes;
use std::ffi::OsString;
@@ -28,6 +29,8 @@ pub(crate) struct ClientIdentity {
pub(crate) codex_home: PathBuf,
pub(crate) user_sid: String,
pub(crate) session_id: u32,
// The requested route, authenticated against the held client's installed image.
pub(crate) runtime: SetupRuntime,
pub(crate) token: OwnedHandle,
pub(crate) desktop_installation: Option<crate::installation_record::DesktopInstallation>,
// Pins and guards remain live through registration and the provisioning helper.
@@ -74,6 +77,14 @@ fn authenticate_impersonated_client(
sandbox_sid: &[u8],
request: &ServiceRequest,
) -> Result<ClientIdentity> {
// Pin the group generation through identity capture.
// Release this lock before the provisioning worker checks machine policy.
let _setup_lock = codex_windows_sandbox::acquire_sandbox_setup_lock(/*timeout_ms*/ 5_000)?;
anyhow::ensure!(
codex_windows_sandbox::resolve_sid(codex_windows_sandbox::SANDBOX_USERS_GROUP)
.is_ok_and(|current| current == sandbox_sid),
codex_windows_sandbox::SANDBOX_GROUP_CHANGED
);
if unsafe { pipes::ImpersonateNamedPipeClient(pipe) } == 0 {
return Err(std::io::Error::last_os_error()).context("impersonate provisioning client");
}
@@ -130,12 +141,21 @@ fn authenticate_impersonated_client(
let user_sid = string_from_sid_bytes(&user).map_err(anyhow::Error::msg)?;
let account = unsafe { codex_windows_sandbox::account_name_from_sid(user.as_ptr() as _) }
.context("resolve provisioning client account")?;
let runtime = crate::package_identity::authorize_setup_runtime(authorized_process, request)?;
let requested_home = match request {
ServiceRequest::RegisterInstallation { codex_home } => codex_home,
ServiceRequest::ProvisionSandbox(request) => &request.codex_home,
};
let (codex_home, handles) = match request {
ServiceRequest::ProvisionSandbox(_) => prepare_codex_home(requested_home)?,
ServiceRequest::ProvisionSandbox(request) => prepare_codex_home(
requested_home,
runtime,
if request.refresh_only {
DirectoryOpenDisposition::OpenExisting
} else {
DirectoryOpenDisposition::OpenOrCreate
},
)?,
ServiceRequest::RegisterInstallation { .. } => {
// Registration must not create the sandbox directories or change their ACLs.
codex_windows_sandbox::validate_local_directory_path(requested_home)?;
@@ -195,6 +215,7 @@ fn authenticate_impersonated_client(
codex_home,
user_sid,
session_id: session,
runtime,
token,
desktop_installation,
directory_handles: handles,
@@ -5,6 +5,7 @@ use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use codex_windows_sandbox::DirectoryOpenDisposition;
use codex_windows_sandbox::SetupRuntime;
use codex_windows_sandbox::create_directory_guard;
use codex_windows_sandbox::open_directory_no_reparse;
use codex_windows_sandbox::to_wide;
@@ -33,7 +34,15 @@ impl Drop for OwnedHandle {
}
}
pub(super) fn prepare_codex_home(requested: &Path) -> Result<(PathBuf, Vec<OwnedHandle>)> {
pub(super) fn prepare_codex_home(
requested: &Path,
runtime: SetupRuntime,
disposition: DirectoryOpenDisposition,
) -> Result<(PathBuf, Vec<OwnedHandle>)> {
let directory_count = match runtime {
SetupRuntime::Registered => 3,
SetupRuntime::Legacy => 4,
};
let mut handles = Vec::new();
validate_local_directory_path(requested)?;
let requested_root = requested
@@ -52,7 +61,7 @@ pub(super) fn prepare_codex_home(requested: &Path) -> Result<(PathBuf, Vec<Owned
handles.push(pin_directory(
requested,
filesystem::FILE_READ_ATTRIBUTES,
DirectoryOpenDisposition::OpenOrCreate,
disposition,
)?);
let home = requested
.canonicalize()
@@ -76,6 +85,7 @@ pub(super) fn prepare_codex_home(requested: &Path) -> Result<(PathBuf, Vec<Owned
&sandbox_bin,
]
.into_iter()
.take(directory_count)
.enumerate()
{
let mut access = filesystem::FILE_READ_ATTRIBUTES
@@ -84,7 +94,7 @@ pub(super) fn prepare_codex_home(requested: &Path) -> Result<(PathBuf, Vec<Owned
if index != 0 {
access |= filesystem::WRITE_DAC;
}
let handle = pin_directory(directory, access, DirectoryOpenDisposition::OpenOrCreate)
let handle = pin_directory(directory, access, disposition)
.map_err(|error| {
// Older elevated installs omitted WRITE_DAC; let the interactive helper repair it.
if directory == sandbox_bin.as_path()
@@ -1,7 +1,9 @@
//! Owns the provisioning pipe and its security descriptor for the listener lifetime.
//! Owns a provisioning pipe and the sandbox group generation in its deny rule.
//! Replaces stale listeners only after a response, closing the old pipe before creating another.
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use codex_windows_sandbox::ensure_sandbox_users_group;
use codex_windows_sandbox::string_from_sid_bytes;
use codex_windows_sandbox::to_wide;
@@ -25,14 +27,39 @@ pub(super) struct ProvisioningListener {
impl ProvisioningListener {
pub(super) fn open() -> Result<Self> {
let pipe_name = codex_windows_sandbox::windows_sandbox_service_pipe_name()?;
let _setup_lock =
codex_windows_sandbox::acquire_sandbox_setup_lock(/*timeout_ms*/ 5_000)?;
if crate::installation_record::load()?
.and_then(|record| record.runtime)
.is_some_and(|runtime| runtime.retiring.is_some())
{
bail!("interrupted sandbox cleanup requires repair");
}
let sandbox_sid = ensure_sandbox_users_group()?;
let (descriptor, pipe) = create_provisioning_pipe(super::PIPE_NAME, &sandbox_sid)?;
let (descriptor, pipe) = create_provisioning_pipe(&pipe_name, &sandbox_sid)?;
Ok(Self {
pipe,
sandbox_sid,
_descriptor: descriptor,
})
}
pub(super) fn refresh(self) -> Result<Self> {
let current_group = {
let _setup_lock =
codex_windows_sandbox::acquire_sandbox_setup_lock(/*timeout_ms*/ 5_000)?;
codex_windows_sandbox::resolve_sid(codex_windows_sandbox::SANDBOX_USERS_GROUP)
};
if current_group.is_ok_and(|current| current == self.sandbox_sid) {
unsafe { pipes::DisconnectNamedPipe(self.pipe.0) };
return Ok(self);
}
// A stale deny SID was refused before dispatch. Close this first-instance
// pipe before rebuilding its descriptor for the current group generation.
drop(self);
Self::open()
}
}
struct SecurityDescriptor(security::PSECURITY_DESCRIPTOR);
@@ -20,8 +20,10 @@ pub(crate) enum ServiceRequest {
#[derive(Debug, Eq, PartialEq)]
pub(crate) struct ProvisioningRequest {
pub(super) codex_home: PathBuf,
pub(super) listeners: WindowsSandboxProxyListeners,
pub(crate) codex_home: PathBuf,
pub(crate) registered_core: bool,
pub(crate) refresh_only: bool,
pub(crate) listeners: WindowsSandboxProxyListeners,
pub(crate) settings: WindowsSandboxProvisioningSettings,
}
@@ -36,6 +38,7 @@ pub(super) fn validate_request(request: &[u8]) -> Result<ServiceRequest> {
if !reader.is_empty() {
bail!("provisioning requests must contain exactly one IPC frame");
}
if frame.version != PROVISIONING_PROTOCOL_VERSION {
bail!(
"unsupported provisioning request version: {}",
@@ -53,6 +56,9 @@ pub(super) fn validate_request(request: &[u8]) -> Result<ServiceRequest> {
ProvisioningMessage::ProvisionSandboxResponse { .. } => bail!("expected a service request"),
};
validate_home(&request.codex_home)?;
if request.refresh_only && !request.registered_core {
bail!("registration refresh requires registered Core");
}
let mut settings = request.settings;
let mut listeners = request.listeners;
for ports in [
@@ -76,6 +82,8 @@ pub(super) fn validate_request(request: &[u8]) -> Result<ServiceRequest> {
}
Ok(ServiceRequest::ProvisionSandbox(ProvisioningRequest {
codex_home: PathBuf::from(request.codex_home),
registered_core: request.registered_core,
refresh_only: request.refresh_only,
listeners,
settings,
}))
@@ -1,18 +1,22 @@
use super::OwnedHandle;
use super::PipeConnection;
use super::ProvisioningRequest;
use super::ServiceRequest;
use super::accept_pipe_connection;
use super::home::prepare_codex_home;
use super::is_config_parse_error;
use super::pin_existing_ancestors;
use super::pipe_security_descriptor;
use super::refresh_session;
use super::request::ProvisioningRequest;
use super::validate_request;
use super::wake;
use codex_windows_sandbox::DirectoryOpenDisposition;
use codex_windows_sandbox::FramedProvisioningMessage;
use codex_windows_sandbox::PROVISIONING_PROTOCOL_VERSION;
use codex_windows_sandbox::ProvisioningMessage;
use codex_windows_sandbox::SandboxProvisioningRequest;
use codex_windows_sandbox::SandboxProvisioningResponse;
use codex_windows_sandbox::SetupRuntime;
use codex_windows_sandbox::WindowsSandboxProvisioningSettings;
use codex_windows_sandbox::WindowsSandboxProxyListeners;
use codex_windows_sandbox::read_provisioning_frame;
@@ -22,6 +26,7 @@ use pretty_assertions::assert_eq;
use std::path::Path;
use std::path::PathBuf;
use std::ptr;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use std::sync::mpsc;
@@ -32,6 +37,141 @@ use windows_sys::Win32::Storage::FileSystem as filesystem;
use windows_sys::Win32::Storage::Packaging::Appx;
use windows_sys::Win32::System::Pipes as pipes;
#[test]
fn session_refresh_observes_cleanup_completion_before_dispatch() {
let shutdown = AtomicBool::new(false);
assert!(
!refresh_session(&shutdown, || {
shutdown.store(true, Ordering::Release);
Ok(())
})
.unwrap()
);
}
#[test]
fn session_refresh_preserves_authoritative_stop() {
let shutdown = AtomicBool::new(true);
assert!(!refresh_session(&shutdown, || panic!("stopped listener must not refresh")).unwrap());
assert!(shutdown.load(Ordering::Acquire));
}
#[test]
fn session_refresh_keeps_admission_open_without_cleanup() {
let shutdown = AtomicBool::new(false);
assert!(refresh_session(&shutdown, || Ok(())).unwrap());
assert!(!shutdown.load(Ordering::Acquire));
}
#[test]
fn session_refresh_propagates_cleanup_failure() {
let shutdown = AtomicBool::new(false);
let error = refresh_session(&shutdown, || anyhow::bail!("cleanup failed")).unwrap_err();
assert!(error.to_string().contains("cleanup failed"));
}
#[test]
fn provisioning_request_rejects_malformed_registered_flag() {
for registered_core in [
serde_json::json!("true"),
serde_json::json!(1),
serde_json::json!(null),
] {
let bytes = serde_json::to_vec(&serde_json::json!({
"version": PROVISIONING_PROTOCOL_VERSION,
"type": "provision_sandbox_request",
"payload": {
"codex_home": "C:\\Users\\owner\\.codex",
"registered_core": registered_core,
"settings": WindowsSandboxProvisioningSettings::default(),
"listeners": WindowsSandboxProxyListeners::default(),
},
}))
.unwrap();
let mut frame = (bytes.len() as u32).to_le_bytes().to_vec();
frame.extend(bytes);
assert!(validate_request(&frame).is_err());
}
}
#[test]
fn runtime_registration_is_an_explicit_wire_opt_in() {
let request = SandboxProvisioningRequest {
codex_home: r"C:\Users\owner\.codex".to_string(),
registered_core: false,
refresh_only: false,
settings: WindowsSandboxProvisioningSettings::default(),
listeners: WindowsSandboxProxyListeners::default(),
};
let serialized = serde_json::to_value(&request).unwrap();
assert_eq!(
serialized,
serde_json::json!({
"codex_home": request.codex_home,
"settings": request.settings,
"listeners": request.listeners,
}),
);
let legacy: SandboxProvisioningRequest = serde_json::from_value(serialized).unwrap();
assert!(matches!(
validate_request(&framed_request(legacy)).unwrap(),
ServiceRequest::ProvisionSandbox(ProvisioningRequest {
registered_core: false,
refresh_only: false,
..
})
));
let registered = SandboxProvisioningRequest {
registered_core: true,
refresh_only: false,
..request
};
assert!(matches!(
validate_request(&framed_request(registered)).unwrap(),
ServiceRequest::ProvisionSandbox(ProvisioningRequest {
registered_core: true,
refresh_only: false,
..
})
));
}
#[test]
fn registration_refresh_is_explicit_and_requires_registered_core() {
let mut request = SandboxProvisioningRequest {
codex_home: r"C:\Users\owner\.codex".to_string(),
registered_core: false,
refresh_only: true,
settings: WindowsSandboxProvisioningSettings::default(),
listeners: WindowsSandboxProxyListeners::default(),
};
assert!(validate_request(&framed_request(request.clone())).is_err());
request.registered_core = true;
assert!(matches!(
validate_request(&framed_request(request)).unwrap(),
ServiceRequest::ProvisionSandbox(ProvisioningRequest {
registered_core: true,
refresh_only: true,
..
})
));
}
#[test]
fn runtime_registration_has_no_unpackaged_or_foreground_identity_exemption() {
let service = Some("OpenAI.Codex_testpublisher");
assert!(crate::package_identity::require_runtime_package_family(service, service).is_ok());
for (client, service) in [
(None, service),
(service, None),
(None, None),
(Some("OpenAI.CodexBeta_testpublisher"), service),
(Some("OpenAI.Codex_otherpublisher"), service),
] {
assert!(crate::package_identity::require_runtime_package_family(client, service).is_err());
}
}
fn framed_request(request: SandboxProvisioningRequest) -> Vec<u8> {
let mut frame = Vec::new();
write_provisioning_frame(
@@ -110,6 +250,8 @@ fn config_parse_errors_are_distinguished_from_policy_and_io_failures() {
fn provisioning_request_preserves_home_spaces_and_unicode() {
let request = framed_request(SandboxProvisioningRequest {
codex_home: "D:\\Codex Homes\\Jos\u{00e9}\\.codex".to_string(),
registered_core: false,
refresh_only: false,
settings: WindowsSandboxProvisioningSettings::default(),
listeners: WindowsSandboxProxyListeners::default(),
});
@@ -117,6 +259,8 @@ fn provisioning_request_preserves_home_spaces_and_unicode() {
validate_request(&request).unwrap(),
ServiceRequest::ProvisionSandbox(ProvisioningRequest {
codex_home: PathBuf::from("D:\\Codex Homes\\Jos\u{00e9}\\.codex"),
registered_core: false,
refresh_only: false,
listeners: WindowsSandboxProxyListeners::default(),
settings: WindowsSandboxProvisioningSettings::default(),
})
@@ -130,6 +274,8 @@ fn structured_provisioning_request_carries_normalized_proxy_settings() {
{
let request = framed_request(SandboxProvisioningRequest {
codex_home: "D:\\Codex Homes\\Jos\u{00e9}\\.codex".to_string(),
registered_core: false,
refresh_only: false,
settings: WindowsSandboxProvisioningSettings {
proxy_ports: vec![http_port, socks_port, http_port],
allow_local_binding: true,
@@ -143,6 +289,8 @@ fn structured_provisioning_request_carries_normalized_proxy_settings() {
validate_request(&request).unwrap(),
ServiceRequest::ProvisionSandbox(ProvisioningRequest {
codex_home: PathBuf::from("D:\\Codex Homes\\Jos\u{00e9}\\.codex"),
registered_core: false,
refresh_only: false,
listeners: WindowsSandboxProxyListeners {
http_ports: vec![http_port],
socks_ports: vec![socks_port],
@@ -173,6 +321,8 @@ fn structured_provisioning_request_accepts_independent_and_additional_proxy_port
};
let request = framed_request(SandboxProvisioningRequest {
codex_home: r"C:\Users\alice\.codex".to_string(),
registered_core: false,
refresh_only: false,
settings: settings.clone(),
listeners: listeners.clone(),
});
@@ -180,6 +330,8 @@ fn structured_provisioning_request_accepts_independent_and_additional_proxy_port
validate_request(&request).unwrap(),
ServiceRequest::ProvisionSandbox(ProvisioningRequest {
codex_home: PathBuf::from(r"C:\Users\alice\.codex"),
registered_core: false,
refresh_only: false,
settings,
listeners,
})
@@ -191,6 +343,8 @@ fn structured_provisioning_request_accepts_independent_and_additional_proxy_port
fn structured_provisioning_request_accepts_disabled_listeners() {
let request = framed_request(SandboxProvisioningRequest {
codex_home: r"C:\Users\alice\.codex".to_string(),
registered_core: false,
refresh_only: false,
settings: WindowsSandboxProvisioningSettings::default(),
listeners: WindowsSandboxProxyListeners::default(),
});
@@ -198,6 +352,8 @@ fn structured_provisioning_request_accepts_disabled_listeners() {
validate_request(&request).unwrap(),
ServiceRequest::ProvisionSandbox(ProvisioningRequest {
codex_home: PathBuf::from(r"C:\Users\alice\.codex"),
registered_core: false,
refresh_only: false,
listeners: WindowsSandboxProxyListeners::default(),
settings: WindowsSandboxProvisioningSettings::default(),
})
@@ -208,6 +364,8 @@ fn structured_provisioning_request_accepts_disabled_listeners() {
fn structured_provisioning_request_requires_exact_version_fields_and_framing() {
let valid = SandboxProvisioningRequest {
codex_home: r"C:\Users\alice\.codex".to_string(),
registered_core: false,
refresh_only: false,
settings: WindowsSandboxProvisioningSettings::default(),
listeners: WindowsSandboxProxyListeners::default(),
};
@@ -254,6 +412,8 @@ fn structured_provisioning_request_rejects_invalid_or_inconsistent_ports() {
] {
let request = framed_request(SandboxProvisioningRequest {
codex_home: r"C:\Users\alice\.codex".to_string(),
registered_core: false,
refresh_only: false,
settings: WindowsSandboxProvisioningSettings {
proxy_ports,
allow_local_binding: false,
@@ -272,6 +432,8 @@ fn provisioning_request_rejects_empty_control_characters_and_invalid_utf8() {
for home in ["", "C:\\safe\0evil", "C:\\safe\rmore", "C:\\safe\nmore"] {
let request = framed_request(SandboxProvisioningRequest {
codex_home: home.to_string(),
registered_core: false,
refresh_only: false,
settings: WindowsSandboxProvisioningSettings::default(),
listeners: WindowsSandboxProxyListeners::default(),
});
@@ -280,6 +442,8 @@ fn provisioning_request_rejects_empty_control_characters_and_invalid_utf8() {
let mut invalid_utf8 = framed_request(SandboxProvisioningRequest {
codex_home: r"C:\Users\alice\.codex".to_string(),
registered_core: false,
refresh_only: false,
settings: WindowsSandboxProvisioningSettings::default(),
listeners: WindowsSandboxProxyListeners::default(),
});
@@ -499,6 +663,71 @@ fn pipe_descriptor_denies_sandbox_group_before_interactive_users() {
assert!(deny < interactive);
}
#[test]
fn registered_home_preparation_does_not_touch_the_legacy_bin() -> anyhow::Result<()> {
let root = std::env::temp_dir().join(format!(
"codex-service-home-{:?}",
windows::core::GUID::new()?
));
std::fs::create_dir(&root)?;
let result = (|| -> anyhow::Result<()> {
let home = root.join(".codex");
std::fs::create_dir(&home)?;
let bin = home.join(".sandbox-bin");
let marker = b"legacy bin is deliberately not a directory";
std::fs::write(&bin, marker)?;
assert!(
prepare_codex_home(
&home,
SetupRuntime::Registered,
DirectoryOpenDisposition::OpenExisting,
)
.is_err()
);
assert!(!home.join(".sandbox").exists());
assert!(!home.join(".sandbox-secrets").exists());
let (_, handles) = prepare_codex_home(
&home,
SetupRuntime::Registered,
DirectoryOpenDisposition::OpenOrCreate,
)?;
assert!(home.join(".sandbox").is_dir());
assert!(home.join(".sandbox-secrets").is_dir());
assert_eq!(std::fs::read(&bin)?.as_slice(), marker.as_slice());
drop(handles);
// Legacy still validates and pins the copied-bin path.
let (_, handles) = prepare_codex_home(
&home,
SetupRuntime::Registered,
DirectoryOpenDisposition::OpenExisting,
)?;
drop(handles);
assert!(
prepare_codex_home(
&home,
SetupRuntime::Legacy,
DirectoryOpenDisposition::OpenOrCreate,
)
.is_err()
);
assert_eq!(std::fs::read(&bin)?.as_slice(), marker.as_slice());
std::fs::remove_file(&bin)?;
let (_, handles) = prepare_codex_home(
&home,
SetupRuntime::Legacy,
DirectoryOpenDisposition::OpenOrCreate,
)?;
assert!(bin.is_dir());
drop(handles);
Ok(())
})();
std::fs::remove_dir_all(&root)?;
result
}
#[test]
fn installation_registration_requires_no_sandbox_settings() {
let mut frame = Vec::new();
@@ -4,6 +4,7 @@ use anyhow::Result;
mod installation_record;
#[cfg(windows)]
mod ipc;
#[cfg(windows)]
mod machine_policy;
#[cfg(windows)]
@@ -13,6 +14,8 @@ mod package_lifecycle;
#[cfg(windows)]
mod provisioning;
#[cfg(windows)]
mod registered_runtime;
#[cfg(windows)]
mod service;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -15,6 +15,11 @@ use windows_sys::Win32::Security as security;
use windows_sys::Win32::System::Pipes;
use windows_sys::Win32::System::Threading;
mod registered;
pub(crate) use registered::authorize_setup_runtime;
#[cfg(test)]
pub(crate) use registered::require_runtime_package_family;
#[cfg(debug_assertions)]
static FOREGROUND_MODE: AtomicBool = AtomicBool::new(false);
@@ -0,0 +1,75 @@
//! Admits registered Core only from the service's installed package version and
//! prevents legacy setup from replacing accounts owned by a registered runtime.
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use codex_windows_sandbox::SetupRuntime;
use windows_sys::Win32::System::Threading;
use super::AuthorizedClientProcess;
use crate::ipc::ServiceRequest;
pub(crate) fn authorize_setup_runtime(
process: &AuthorizedClientProcess,
request: &ServiceRequest,
) -> Result<SetupRuntime> {
match request {
ServiceRequest::ProvisionSandbox(request) if request.registered_core => {
authorize_runtime_client_process(process)?;
Ok(SetupRuntime::Registered)
}
ServiceRequest::ProvisionSandbox(_) => {
anyhow::ensure!(
crate::installation_record::load_runtime()?.is_none(),
"registered Core owns these sandbox accounts; legacy provisioning is not permitted"
);
Ok(SetupRuntime::Legacy)
}
ServiceRequest::RegisterInstallation { .. } => Ok(SetupRuntime::Legacy),
}
}
fn authorize_runtime_client_process(process: &AuthorizedClientProcess) -> Result<()> {
let client_family = unsafe { codex_windows_sandbox::process_package_family(process.handle.0) }
.context("read runtime registration client package family")?;
let service_family =
unsafe { codex_windows_sandbox::process_package_family(Threading::GetCurrentProcess()) }
.context("read runtime registration service package family")?;
require_runtime_package_family(client_family.as_deref(), service_family.as_deref())?;
let mut image = [0u16; 32768];
let mut length = image.len() as u32;
if unsafe {
Threading::QueryFullProcessImageNameW(process.handle.0, 0, image.as_mut_ptr(), &mut length)
} == 0
{
return Err(std::io::Error::last_os_error()).context("read provisioning client image");
}
let image = std::path::PathBuf::from(String::from_utf16(&image[..length as usize])?);
let image = image.canonicalize()?;
let service = std::env::current_exe()?.canonicalize()?;
let directory = service.parent().context("service image has no directory")?;
anyhow::ensure!(
["codex.exe", "codex-code-mode-host.exe"]
.iter()
.any(|name| {
image
.as_os_str()
.eq_ignore_ascii_case(directory.join(name).as_os_str())
}),
"registered Core caller is not from this installed service version"
);
Ok(())
}
pub(crate) fn require_runtime_package_family(
client: Option<&str>,
service: Option<&str>,
) -> Result<()> {
match (client, service) {
(Some(client), Some(service)) if client == service => Ok(()),
_ => bail!(
"runtime registration requires a client in the service's installed package family"
),
}
}
@@ -1,4 +1,5 @@
//! Restores owner-scoped uninstall notifications and ties file cleanup to pinned directories.
//! Registered runtime policy stays in its private module; owner tokens and pins stay here.
use std::cell::RefCell;
use std::io;
@@ -7,13 +8,14 @@ use std::os::windows::io::IntoRawHandle;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use anyhow::Context;
use anyhow::Result;
use anyhow::ensure;
use codex_windows_sandbox::DirectoryOpenDisposition;
use codex_windows_sandbox::PreparedWindowsSandboxCleanup;
use codex_windows_sandbox::create_directory_guard;
use codex_windows_sandbox::prepare_packaged_windows_sandbox_cleanup;
use codex_windows_sandbox::string_from_sid_bytes;
use windows::ApplicationModel::Package;
use windows::ApplicationModel::PackageCatalog;
@@ -36,6 +38,9 @@ use crate::installation_record::InstallationRecord;
use crate::ipc::OwnedHandle;
mod cleanup;
mod registered;
pub(crate) use registered::runtime_owner_removed;
struct UserInstallation {
codex_home: Option<PathBuf>,
@@ -87,7 +92,7 @@ impl PackageLifecycle {
.desktop_installation
.or(record.desktop_installation);
}
crate::installation_record::save(&record)?;
record = crate::installation_record::save(record)?;
if let Some(installation) = active.as_mut()
&& installation.codex_home.is_some()
{
@@ -143,6 +148,7 @@ impl PackageLifecycle {
)?;
let package_name = self.package_name.clone();
let uninstalling = Arc::clone(&self.uninstalling);
let owner_sid = record.user_sid.clone();
let token = catalog
.PackageUninstalling(&TypedEventHandler::<
PackageCatalog,
@@ -151,7 +157,7 @@ impl PackageLifecycle {
if let Some(event) = event
&& event.Package()?.Id()?.FullName()? == package_name
{
uninstalling.store(!event.IsComplete()?, Ordering::Release);
registered::on_package_uninstalling(event, &owner_sid, &uninstalling)?;
}
Ok(())
}))
@@ -208,6 +214,11 @@ impl PackageLifecycle {
return Ok(());
};
if record.runtime.is_some()
&& !crate::installation_record::is_current_package_family(&record)?
{
return Ok(());
}
let mut raw_token = 0;
if unsafe { WTSQueryUserToken(session_id, &mut raw_token) } == 0 {
return Err(io::Error::last_os_error()).context("open the logged-in user's token");
@@ -231,11 +242,29 @@ impl PackageLifecycle {
}
pub(crate) fn clean_up(&self) -> Result<()> {
let _setup_lock =
codex_windows_sandbox::acquire_sandbox_setup_lock(/*timeout_ms*/ 5_000)?;
if let Some(record) = crate::installation_record::load_runtime()? {
return registered::clean_up(self, record);
}
// Preserve the legacy path's ownership retirement before native preparation.
crate::installation_record::remove()?;
self.clean_up_resources(
&prepare_packaged_windows_sandbox_cleanup()?,
/*runtime*/ None,
)
}
fn clean_up_resources(
&self,
prepared: &PreparedWindowsSandboxCleanup,
runtime: Option<&InstallationRecord>,
) -> Result<()> {
let mut installation = self.installation.borrow_mut();
let installation = installation
.as_mut()
.context("the authenticated package installation was not recorded")?;
cleanup::clean_up(installation)
cleanup::clean_up(installation, prepared, runtime)
}
}
@@ -1,29 +1,43 @@
//! Removes one authenticated owner's sandbox resources during package uninstall.
//! Desktop paths are deleted only while impersonating the owner; native cleanup keeps its order.
//! Removes one authenticated owner's sandbox resources using prepared native cleanup.
//! Owner impersonation, directory pins, and registration-aware cleanup order are preserved.
use std::io;
use anyhow::Context;
use anyhow::Result;
use anyhow::ensure;
use codex_windows_sandbox::clean_up_packaged_windows_sandbox;
use codex_windows_sandbox::PreparedWindowsSandboxCleanup;
use codex_windows_sandbox::resolve_sid;
use codex_windows_sandbox::revoke_ace;
use super::UserInstallation;
use super::with_owner_impersonation;
use crate::installation_record::InstallationRecord;
pub(super) fn clean_up(installation: &mut UserInstallation) -> Result<()> {
pub(super) fn clean_up(
installation: &mut UserInstallation,
prepared: &PreparedWindowsSandboxCleanup,
runtime: Option<&InstallationRecord>,
) -> Result<()> {
crate::service::log_information(
crate::service::EVENT_CLEANUP_STARTED,
"sandbox uninstall cleanup started",
);
// A partial uninstall must not let a later install inherit stale directory ownership.
crate::installation_record::remove()?;
let codex_home = installation.codex_home.clone();
clean_up_packaged_windows_sandbox(codex_home.as_deref(), || {
// Privileged file cleanup is finished; release the guard before owner-scoped removal.
// Remove exact grants from the locked cleanup record before native account deletion.
if let Some(record) = runtime {
crate::registered_runtime::remove_metadata(installation.user_token.0, record)?;
}
let result = prepared.finish(codex_home.as_deref(), || {
// Release once, even when registered cleanup retries its remaining steps.
installation.directory_guard.take();
if let Some(record) = runtime
&& super::registered::runtime_owner_has_package(record)?
{
// Only the old sandbox resources are repaired on reinstall. Never remove
// the reinstalled app's desktop-created home or runtime cache.
return Ok(());
}
let Some(desktop) = &installation.record.desktop_installation else {
return Ok(());
};
@@ -38,7 +52,7 @@ pub(super) fn clean_up(installation: &mut UserInstallation) -> Result<()> {
}
};
if let Some(home) = &codex_home {
if desktop.created_codex_home {
if desktop.created_codex_home && runtime.is_none() {
// Release the home itself so it can be deleted; keep its ancestors pinned.
installation.directory_handles.pop();
record_error(std::fs::remove_dir_all(home));
@@ -73,8 +87,8 @@ pub(super) fn clean_up(installation: &mut UserInstallation) -> Result<()> {
);
Ok(())
})
})
.context("remove packaged Windows sandbox resources")?;
});
result.context("remove packaged Windows sandbox resources")?;
crate::service::log_information(
crate::service::EVENT_CLEANUP_FINISHED,
"sandbox uninstall cleanup finished",
@@ -0,0 +1,122 @@
//! Registered runtime ownership and uninstall policy for the shared package watcher.
//! Native cleanup stays inside the caller's setup lock and precedes package removal.
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use anyhow::Context;
use anyhow::Result;
use anyhow::ensure;
use codex_windows_sandbox::prepare_packaged_windows_sandbox_cleanup_with_retained_tokens;
use windows::ApplicationModel::PackageUninstallingEventArgs;
use windows::Management::Deployment::PackageManager;
use windows::core::HSTRING;
use super::PackageLifecycle;
use crate::installation_record::InstallationRecord;
pub(super) fn on_package_uninstalling(
event: &PackageUninstallingEventArgs,
owner_sid: &str,
uninstalling: &AtomicBool,
) -> windows::core::Result<()> {
let retention = crate::installation_record::load_runtime().and_then(|record| match record {
Some(record) if record.user_sid == owner_sid => {
crate::registered_runtime::has_retaining_registration(&record).map(Some)
}
Some(_) => Ok(None),
None => Ok(Some(false)),
});
match retention {
Ok(Some(true)) => {
// Removing an old version during update must not remove the
// managed user's registration. Check the whole family only
// after the owner's uninstall has completed.
if event.IsComplete()? {
crate::service::wake_listener();
}
}
Ok(None) => {}
Ok(Some(false)) => {
// No actual managed registration keeps this service installed,
// even if a failed attempt left intent. Retain SCM-stop cleanup.
uninstalling.store(!event.IsComplete()?, Ordering::Release);
}
Err(error) => crate::service::log_error(
crate::service::EVENT_SERVICE_FAILED,
&format!("unable to read registered runtime ownership: {error:#}"),
),
}
Ok(())
}
/// The shared cleanup entry holds the setup mutex until this function returns.
pub(super) fn clean_up(lifecycle: &PackageLifecycle, record: InstallationRecord) -> Result<()> {
if !crate::installation_record::is_current_package_family(&record)? {
return Ok(());
}
ensure!(
record.runtime()?.retiring.is_none(),
"interrupted runtime cleanup requires repair"
);
ensure!(
!runtime_owner_has_package(&record)?
|| (lifecycle.uninstalling.load(Ordering::Acquire)
&& !crate::registered_runtime::has_retaining_registration(&record)?),
"registered runtime owner still has the app installed"
);
ensure!(
lifecycle
.installation
.borrow()
.as_ref()
.is_some_and(|installation| {
installation.record.user_sid == record.user_sid
&& installation.codex_home.as_ref() == Some(&record.codex_home)
}),
"waiting for the authenticated runtime owner and home before sandbox file cleanup"
);
let record = crate::registered_runtime::prepare_cleanup(record)?;
let removal = {
let installation = lifecycle.installation.borrow();
let installation = installation
.as_ref()
.context("authenticated installation is missing")?;
crate::registered_runtime::prepare_removal(installation.user_token.0, &record)?
};
let prepared =
prepare_packaged_windows_sandbox_cleanup_with_retained_tokens(&removal.tokens())?;
// A reinstall before this boundary cancels without retiring resources.
ensure!(
(lifecycle.uninstalling.load(Ordering::Acquire)
&& !crate::registered_runtime::has_retaining_registration(&record)?)
|| !runtime_owner_has_package(&record)?,
"owner reinstalled before native cleanup started"
);
crate::installation_record::save_runtime(&record)?;
// Retry only in this live invocation, retaining the outer setup lock.
// A stored retirement fence is never proof that native cleanup finished.
crate::service::retry_cleanup(|| lifecycle.clean_up_resources(&prepared, Some(&record)))?;
removal.commit()
}
/// The managed registration must not keep the package alive after its owner
/// uninstalls it, but an update to another version of the family is not uninstall.
pub(crate) fn runtime_owner_removed() -> Result<bool> {
let Some(record) = crate::installation_record::load_runtime()? else {
return Ok(false);
};
Ok(
crate::installation_record::is_current_package_family(&record)?
&& !runtime_owner_has_package(&record)?,
)
}
// Callers have verified that the record belongs to this service's package family.
pub(super) fn runtime_owner_has_package(record: &InstallationRecord) -> Result<bool> {
let packages = PackageManager::new()?.FindPackagesByUserSecurityIdPackageFamilyName(
&HSTRING::from(&record.user_sid),
&HSTRING::from(&record.runtime()?.package_family),
)?;
Ok(packages.First()?.HasCurrent()?)
}
@@ -1,15 +1,18 @@
//! Runs provisioning after IPC has authenticated the owner and checked machine policy.
//! The authenticated token and directory pins retain their existing helper lifetimes.
//! Provisions an authenticated client's sandbox through its selected setup path.
mod registered;
use std::os::windows::fs::MetadataExt;
use std::os::windows::io::BorrowedHandle;
use std::os::windows::io::IntoRawHandle;
use std::sync::atomic::AtomicBool;
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use codex_windows_sandbox::SandboxProvisioningResponse;
use codex_windows_sandbox::SetupRuntime;
use codex_windows_sandbox::run_elevated_provisioning_setup_with_retained_handles;
use codex_windows_sandbox::sandbox_setup_is_complete_with_settings;
use windows_sys::Win32::Storage::FileSystem as filesystem;
use crate::installation_record::InstallationRecord;
@@ -17,29 +20,82 @@ use crate::ipc::ClientIdentity;
use crate::ipc::OwnedHandle;
use crate::ipc::ServiceRequest;
// The lifecycle callback validates ownership and returns the canonical saved record.
fn register_owner(
identity: &ClientIdentity,
on_authenticated_user: &dyn Fn(
InstallationRecord,
OwnedHandle,
codex_windows_sandbox::SetupRuntime,
) -> Result<InstallationRecord>,
) -> Result<InstallationRecord> {
let token = unsafe { BorrowedHandle::borrow_raw(identity.token.0 as _) }
.try_clone_to_owned()
.context("retain authenticated uninstall owner")?;
on_authenticated_user(
InstallationRecord {
codex_home: identity.codex_home.clone(),
user_sid: identity.user_sid.clone(),
session_id: identity.session_id,
desktop_installation: identity.desktop_installation.clone(),
runtime: None,
},
OwnedHandle(token.into_raw_handle() as _),
identity.runtime,
)
}
fn setup_is_complete(
identity: &ClientIdentity,
settings: &codex_windows_sandbox::WindowsSandboxProvisioningSettings,
) -> Result<bool> {
crate::package_lifecycle::with_owner_impersonation(identity.token.0, || {
Ok(
codex_windows_sandbox::sandbox_setup_is_complete_with_settings(
&identity.codex_home,
settings,
),
)
})
}
pub(crate) fn run(
identity: ClientIdentity,
request: ServiceRequest,
register_installation: &dyn Fn(InstallationRecord, OwnedHandle) -> Result<InstallationRecord>,
sandbox_sid: &[u8],
shutdown: &AtomicBool,
on_authenticated_user: &dyn Fn(
InstallationRecord,
OwnedHandle,
codex_windows_sandbox::SetupRuntime,
) -> Result<InstallationRecord>,
) -> Result<SandboxProvisioningResponse> {
// A policy-rejected request must not choose the uninstall owner. Use the
// token already authenticated above instead of impersonating the pipe again.
register_installation(
InstallationRecord {
codex_home: identity.codex_home.clone(),
user_sid: identity.user_sid,
session_id: identity.session_id,
desktop_installation: identity.desktop_installation,
},
identity.token,
)?;
let request = match request {
ServiceRequest::RegisterInstallation { .. } => {
let installation = InstallationRecord {
codex_home: identity.codex_home,
user_sid: identity.user_sid,
session_id: identity.session_id,
desktop_installation: identity.desktop_installation,
runtime: None,
};
on_authenticated_user(installation, identity.token, identity.runtime)?;
return Ok(SandboxProvisioningResponse::Ok);
}
ServiceRequest::ProvisionSandbox(request) => request,
};
if sandbox_setup_is_complete_with_settings(&identity.codex_home, &request.settings) {
if identity.runtime == SetupRuntime::Registered {
return registered::run(
&identity,
request,
sandbox_sid,
shutdown,
on_authenticated_user,
);
}
let settings = request.settings;
register_owner(&identity, on_authenticated_user)?;
if setup_is_complete(&identity, &settings)? {
return Ok(SandboxProvisioningResponse::Ok);
}
let helper = std::env::current_exe()
@@ -65,7 +121,7 @@ pub(crate) fn run(
match run_elevated_provisioning_setup_with_retained_handles(
&identity.codex_home,
&identity.account,
request.settings,
settings,
&retained_handles,
) {
Ok(()) => {
@@ -0,0 +1,129 @@
//! The registered-Core setup transaction: admit, provision, register, then publish readiness.
use std::sync::atomic::AtomicBool;
use anyhow::Context;
use anyhow::Result;
use anyhow::ensure;
use codex_windows_sandbox::SandboxProvisioningResponse;
use crate::ipc::ProvisioningRequest;
use codex_windows_sandbox::string_from_sid_bytes;
use windows::ApplicationModel::Package;
use windows_sys::Win32::NetworkManagement::NetManagement::UF_ACCOUNTDISABLE;
use crate::installation_record::InstallationRecord;
use crate::installation_record::RuntimeRegistration;
use crate::ipc::ClientIdentity;
use crate::ipc::OwnedHandle;
pub(super) fn run(
identity: &ClientIdentity,
request: ProvisioningRequest,
sandbox_sid: &[u8],
shutdown: &AtomicBool,
on_authenticated_user: &dyn Fn(
InstallationRecord,
OwnedHandle,
codex_windows_sandbox::SetupRuntime,
) -> Result<InstallationRecord>,
) -> Result<SandboxProvisioningResponse> {
// Keep admission, account provisioning and package registration in one transaction.
let _setup_lock = codex_windows_sandbox::acquire_sandbox_setup_lock(/*timeout_ms*/ 5_000)?;
ensure!(
codex_windows_sandbox::resolve_sid(codex_windows_sandbox::SANDBOX_USERS_GROUP)
.is_ok_and(|current| current == sandbox_sid),
codex_windows_sandbox::SANDBOX_GROUP_CHANGED
);
let settings = request.settings;
let mut setup_complete = super::setup_is_complete(identity, &settings)?;
if request.refresh_only {
// Startup may update registrations, but must never create accounts or repair teardown.
// Check under the setup lock, before changing ownership or sandbox accounts.
let previous = crate::installation_record::load_runtime()?
.context("registration refresh requires completed setup")?;
let runtime = previous.runtime()?;
let ready = runtime
.ready_package
.as_deref()
.context("registration refresh requires a ready runtime")?;
ensure!(
previous.user_sid == identity.user_sid
&& previous.codex_home == identity.codex_home
&& crate::installation_record::is_current_package_family(&previous)?
&& runtime.ready_for_package(ready)
&& setup_complete,
"registration refresh requires unchanged, ready sandbox ownership and settings"
);
for entry in &runtime.accounts {
let account = entry.account.username();
ensure!(
codex_windows_sandbox::local_user_flags(account)?
.is_some_and(|flags| flags & UF_ACCOUNTDISABLE == 0)
&& string_from_sid_bytes(&codex_windows_sandbox::resolve_sid(account)?)
.map_err(anyhow::Error::msg)?
== entry.user_sid,
"registration refresh cannot replace a sandbox account"
);
}
}
let mut installation = super::register_owner(identity, on_authenticated_user)?;
let result = (|| -> Result<()> {
let package = Package::Current()?.Id()?;
installation.runtime.get_or_insert(RuntimeRegistration {
package_family: package.FamilyName()?.to_string(),
accounts: Vec::new(),
metadata_roots: Vec::new(),
ready_package: None,
retiring: None,
});
// Persist the authenticated owner before setup can rotate shared credentials.
crate::installation_record::save_runtime(&installation)?;
for account in [
codex_windows_sandbox::OFFLINE_USERNAME,
codex_windows_sandbox::ONLINE_USERNAME,
] {
setup_complete &= codex_windows_sandbox::local_user_flags(account)?
.is_some_and(|flags| flags & UF_ACCOUNTDISABLE == 0);
}
if !setup_complete {
// Account state can change outside our setup lock; refresh must never repair it.
ensure!(
!request.refresh_only,
"registration refresh cannot repair sandbox setup"
);
// Setup resolves this name for ACL trustees; it must still name the held token's user.
let setup_owner_sid = codex_windows_sandbox::resolve_sid(&identity.account)?;
ensure!(
string_from_sid_bytes(&setup_owner_sid).map_err(anyhow::Error::msg)?
== identity.user_sid,
"sandbox setup account no longer matches the authenticated owner"
);
codex_windows_sandbox::provision_sandbox_in_process(
&identity.codex_home,
&identity.account,
settings,
identity.runtime,
)?;
}
crate::registered_runtime::provision(
identity,
&mut installation,
&package.FullName()?,
shutdown,
)
})();
result
.inspect_err(|error| {
crate::service::log_error(
crate::service::EVENT_PROVISIONING_FAILED,
&format!("Codex sandbox provisioning failed: {error}"),
);
})
.context("registered sandbox provisioning failed")?;
crate::service::log_information(
crate::service::EVENT_PROVISIONING_SUCCEEDED,
"Codex sandbox provisioning completed successfully.",
);
Ok(SandboxProvisioningResponse::Ok)
}
@@ -0,0 +1,328 @@
//! Owns managed-account registration, AppData metadata access, and deferred removal.
//! Lifecycle and admission decisions stay with callers. Persist ownership before deployment;
//! retain profiles until Windows completes, or pending resources until service exit on shutdown.
use std::os::windows::io::AsRawHandle;
use std::os::windows::io::OwnedHandle;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use anyhow::ensure;
use codex_windows_sandbox::APP_CORE_RUNNER_ALIAS;
use codex_windows_sandbox::SandboxRuntimeAccount;
use codex_windows_sandbox::logon_existing_sandbox_account;
use codex_windows_sandbox::resolve_sid as lookup_sid;
use codex_windows_sandbox::sandbox_secrets_dir;
use codex_windows_sandbox::string_from_sid_bytes;
use codex_windows_sandbox::to_wide;
use codex_windows_sandbox::token_groups;
use windows::Foundation::AsyncStatus;
use windows::Management::Deployment::DeploymentOptions;
use windows::Management::Deployment::PackageManager;
use windows::Win32::Foundation::BOOL;
use windows::core::HSTRING;
use windows_sys::Win32::Foundation::HANDLE;
use windows_sys::Win32::Security as security;
use windows_sys::Win32::System::SystemServices::SE_GROUP_ENABLED;
use windows_sys::Win32::UI::Shell::FOLDERID_LocalAppData;
use windows_sys::Win32::UI::Shell::LoadUserProfileW;
use windows_sys::Win32::UI::Shell::PROFILEINFOW;
use windows_sys::Win32::UI::Shell::UnloadUserProfile;
use crate::installation_record::InstallationRecord;
use crate::installation_record::RuntimeAccountRegistration;
use crate::ipc::ClientIdentity;
use crate::package_lifecycle::with_owner_impersonation;
mod known_folder;
mod metadata;
mod removal;
pub(crate) use metadata::remove as remove_metadata;
pub(crate) use removal::prepare as prepare_removal;
/// Must escape request handling: pending resources are retained until this service exits.
#[derive(Debug)]
pub(crate) struct RegistrationInterrupted;
impl std::fmt::Display for RegistrationInterrupted {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("runtime setup interrupted; stopping service")
}
}
impl std::error::Error for RegistrationInterrupted {}
struct AccountProfile {
user_sid: String,
token: OwnedHandle,
profile: HANDLE,
}
impl Drop for AccountProfile {
fn drop(&mut self) {
let _ = self.unload_profile();
}
}
impl AccountProfile {
fn unload_profile(&mut self) -> Result<()> {
if self.profile != 0 {
let unloaded =
unsafe { UnloadUserProfile(self.token.as_raw_handle() as _, self.profile) };
BOOL(unloaded)
.ok()
.context("unload managed runtime account profile")?;
self.profile = 0;
}
Ok(())
}
}
/// The caller holds the setup mutex from owner admission through readiness.
/// Provision both users together. No runner launch performs package deployment.
pub(crate) fn provision(
identity: &ClientIdentity,
record: &mut InstallationRecord,
full_name: &HSTRING,
shutdown: &AtomicBool,
) -> Result<()> {
let family = record.runtime()?.package_family.clone();
validate_record(record)?;
let mut profiles = Vec::new();
let mut accounts = Vec::new();
for account in [
SandboxRuntimeAccount::Offline,
SandboxRuntimeAccount::Online,
] {
let token = with_owner_impersonation(identity.token.0, || {
let mut pins = Vec::new();
crate::ipc::pin_existing_ancestors(
&sandbox_secrets_dir(&identity.codex_home),
&mut pins,
)?;
logon_existing_sandbox_account(&identity.codex_home, account)
})?;
let expected = record
.runtime()?
.accounts
.iter()
.find(|entry| entry.account == account);
let profile = load_profile(
token,
account,
expected.map(|entry| entry.user_sid.as_str()),
)?;
let installed = registered_packages(&profile.user_sid, &family)?;
if expected.is_none() {
ensure!(
installed.is_empty(),
"sandbox account has a runtime registration not owned by this service"
);
}
let alias_path = known_folder::path(
profile.token.as_raw_handle() as _,
&FOLDERID_LocalAppData,
/*flags*/ 0,
)?
.join(r"Microsoft\WindowsApps")
.join(&family)
.join(APP_CORE_RUNNER_ALIAS);
accounts.push(RuntimeAccountRegistration {
account,
user_sid: profile.user_sid.clone(),
alias_path: Some(alias_path),
});
profiles.push((profile, installed.contains(full_name)));
}
let receipt_current = record.runtime()?.ready_for_package(&full_name.to_string())
&& record.runtime()?.accounts == accounts
&& profiles.iter().all(|(_, installed)| *installed);
// Both exact SIDs are durable before either package registration or metadata grant.
// An idempotent setup check must not temporarily revoke a valid launch receipt.
record.runtime_mut()?.accounts = accounts;
if !receipt_current {
record.runtime_mut()?.ready_package = None;
crate::installation_record::save_runtime(record)?;
}
for (mut profile, installed) in profiles {
if !installed {
let operation = with_owner_impersonation(profile.token.as_raw_handle() as _, || {
Ok(PackageManager::new()?.RegisterPackageByFullNameAsync(
full_name,
None,
DeploymentOptions::None,
)?)
})?;
// As with provisioning, retain the request's resources until Windows finishes.
// A client timeout or an unreadable status cannot safely release the profile.
let mut status_error_logged = false;
loop {
if shutdown.load(Ordering::Acquire) {
// The typed error bypasses all response conversion and cleanup. Keep the
// in-flight operation and its profile alive until this own-process service exits.
std::mem::forget((operation, profile));
return Err(RegistrationInterrupted.into());
}
match operation.Status() {
Ok(AsyncStatus::Started) => {}
Ok(AsyncStatus::Completed | AsyncStatus::Canceled | AsyncStatus::Error) => {
break;
}
status => {
if !status_error_logged {
crate::service::log_error(
crate::service::EVENT_SERVICE_FAILED,
&format!(
"runtime registration status unavailable; retaining request: {status:?}"
),
);
status_error_logged = true;
}
}
}
std::thread::sleep(Duration::from_millis(50));
}
operation.GetResults()?.ExtendedErrorCode()?.ok()?;
ensure!(
registered_packages(&profile.user_sid, &family)?.contains(full_name),
"runtime registration completed without the expected package"
);
}
metadata::grant(identity.token.0, &profile.user_sid, record)?;
profile.unload_profile()?;
}
if !receipt_current {
record.runtime_mut()?.ready_package = Some(full_name.to_string());
crate::installation_record::save_runtime(record)?;
}
Ok(())
}
/// Prepare one retirement fence under the caller's setup lock. Removal preparation
/// then retains exact account logons before the native guard disables those users.
pub(crate) fn prepare_cleanup(mut record: InstallationRecord) -> Result<InstallationRecord> {
validate_record(&record)?;
for account in &record.runtime()?.accounts {
if codex_windows_sandbox::local_user_flags(account.account.username())?.is_some() {
let sid = lookup_sid(account.account.username())?;
ensure!(
string_from_sid_bytes(&sid).map_err(anyhow::Error::msg)? == account.user_sid,
"managed runtime account was replaced before cleanup"
);
}
}
ensure!(
record.runtime()?.retiring.is_none(),
"interrupted runtime cleanup requires repair"
);
record.runtime_mut()?.ready_package = None;
record.runtime_mut()?.retiring = Some(format!("{:?}", windows::core::GUID::new()?));
Ok(record)
}
fn validate_record(record: &InstallationRecord) -> Result<()> {
let runtime = record.runtime()?;
ensure!(
runtime.accounts.len() <= 2,
"runtime registration record has too many accounts"
);
if runtime.accounts.len() == 2 {
ensure!(
runtime.accounts[0].account != runtime.accounts[1].account,
"runtime registration record contains duplicate accounts"
);
}
Ok(())
}
fn registered_packages(user_sid: &str, family: &str) -> Result<Vec<HSTRING>> {
let packages = PackageManager::new()?.FindPackagesByUserSecurityIdPackageFamilyName(
&HSTRING::from(user_sid),
&HSTRING::from(family),
)?;
// The Rust Iterator adapter suppresses Current/MoveNext errors. Absence must
// be authoritative before releasing cleanup ownership or its package anchor.
let iterator = packages.First()?;
let mut names = Vec::new();
let mut has_current = iterator.HasCurrent()?;
while has_current {
names.push(iterator.Current()?.Id()?.FullName()?);
has_current = iterator.MoveNext()?;
}
Ok(names)
}
/// An intent is not an anchor: failed registration may leave no managed package.
pub(crate) fn has_retaining_registration(record: &InstallationRecord) -> Result<bool> {
let runtime = record.runtime()?;
for account in &runtime.accounts {
if !registered_packages(&account.user_sid, &runtime.package_family)?.is_empty() {
return Ok(true);
}
}
Ok(false)
}
fn load_profile(
token: OwnedHandle,
account: SandboxRuntimeAccount,
expected_sid: Option<&str>,
) -> Result<AccountProfile> {
let user_sid = validate_target(token.as_raw_handle() as _, account, expected_sid)?;
let mut username = to_wide(account.username());
let mut profile: PROFILEINFOW = unsafe { std::mem::zeroed() };
profile.dwSize = std::mem::size_of::<PROFILEINFOW>() as u32;
profile.dwFlags = 1; // PI_NOUI: service operations never prompt interactively.
profile.lpUserName = username.as_mut_ptr();
unsafe { BOOL(LoadUserProfileW(token.as_raw_handle() as _, &mut profile)).ok() }
.context("load managed runtime account profile")?;
Ok(AccountProfile {
user_sid,
token,
profile: profile.hProfile,
})
}
fn validate_target(
token: HANDLE,
account: SandboxRuntimeAccount,
expected_sid: Option<&str>,
) -> Result<String> {
let user = unsafe { codex_windows_sandbox::get_user_sid_bytes(token) }?;
let user_sid = string_from_sid_bytes(&user).map_err(anyhow::Error::msg)?;
ensure!(
expected_sid.is_none_or(|expected| expected == user_sid),
"managed runtime account SID changed"
);
ensure!(
lookup_sid(account.username())? == user,
"managed runtime account was replaced"
);
let groups = unsafe {
token_groups(token, /*max_bytes*/ 65_536)
}
.context("read runtime account groups")?;
ensure!(groups.len() <= 256, "invalid runtime account groups");
let sandbox_group = lookup_sid(codex_windows_sandbox::SANDBOX_USERS_GROUP)?;
let mut sandbox_member = false;
for group in groups {
ensure!(
string_from_sid_bytes(&group.sid).map_err(anyhow::Error::msg)? != "S-1-5-32-544",
"managed runtime account is an administrator"
);
if unsafe { security::EqualSid(group.sid.as_ptr() as _, sandbox_group.as_ptr() as _) } != 0
&& group.attributes & SE_GROUP_ENABLED as u32 != 0
{
sandbox_member = true;
}
}
ensure!(
sandbox_member,
"runtime account is not in CodexSandboxUsers"
);
Ok(user_sid)
}
@@ -0,0 +1,32 @@
//! Resolves Known Folders for an explicit token and caller-selected flags.
//! Preserve Windows UTF-16 paths and free the returned allocation on every result.
use std::ffi::OsString;
use std::os::windows::ffi::OsStringExt;
use std::path::PathBuf;
use anyhow::Result;
use anyhow::ensure;
use windows_sys::Win32::Foundation::HANDLE;
use windows_sys::Win32::System::Com::CoTaskMemFree;
use windows_sys::Win32::UI::Shell::SHGetKnownFolderPath;
use windows_sys::core::GUID;
pub(super) fn path(token: HANDLE, folder: &GUID, flags: u32) -> Result<PathBuf> {
let mut raw = std::ptr::null_mut();
let status = unsafe { SHGetKnownFolderPath(folder, flags, token, &mut raw) };
let result = (|| {
windows::core::HRESULT(status).ok()?;
ensure!(!raw.is_null(), "Known Folder path missing");
let mut length = 0;
while length < 32768 && unsafe { *raw.add(length) } != 0 {
length += 1;
}
ensure!(length < 32768, "Known Folder path exceeds bound");
Ok(PathBuf::from(OsString::from_wide(unsafe {
std::slice::from_raw_parts(raw, length)
})))
})();
unsafe { CoTaskMemFree(raw.cast()) };
result
}
@@ -0,0 +1,326 @@
//! Gives registered commands metadata access to the owner's two AppData roots.
//! No file contents or descendants are granted. Persist the managed account and
//! each owner-authorized root before granting access, so cleanup includes former roots.
use std::io;
use std::os::windows::io::AsRawHandle;
use std::os::windows::io::OwnedHandle;
use std::path::Path;
use std::ptr;
use anyhow::Context;
use anyhow::Result;
use anyhow::ensure;
use codex_windows_sandbox::DirectoryOpenDisposition;
use codex_windows_sandbox::LocalSid;
use codex_windows_sandbox::open_directory_no_reparse;
use windows::Win32::Foundation::BOOL;
use windows_sys::Win32::Foundation::HANDLE;
use windows_sys::Win32::Foundation::LocalFree;
use windows_sys::Win32::Foundation::RtlNtStatusToDosError;
use windows_sys::Win32::Security as security;
use windows_sys::Win32::Security::Authorization as authorization;
use windows_sys::Win32::Storage::FileSystem as filesystem;
use windows_sys::Win32::UI::Shell as shell;
use super::known_folder::path as known_folder;
use crate::installation_record::InstallationRecord;
use crate::package_lifecycle::with_owner_impersonation;
const OWNER_FOLDER_FLAGS: u32 =
(shell::KF_FLAG_DONT_VERIFY | shell::KF_FLAG_NO_PACKAGE_REDIRECTION) as u32;
#[link(name = "ntdll")]
unsafe extern "system" {
fn NtSetSecurityObject(
handle: HANDLE,
information: u32,
descriptor: *const security::SECURITY_DESCRIPTOR,
) -> i32;
}
pub(super) fn grant(
owner_token: HANDLE,
user_sid: &str,
record: &mut InstallationRecord,
) -> Result<()> {
ensure!(
record
.runtime()?
.accounts
.iter()
.any(|account| account.user_sid == user_sid),
"AppData metadata access requires a recorded managed account"
);
let sid = LocalSid::from_string(user_sid)?;
for folder in [shell::FOLDERID_LocalAppData, shell::FOLDERID_RoamingAppData] {
let path = known_folder(owner_token, &folder, OWNER_FOLDER_FLAGS)?;
edit_root_acl(owner_token, &path, |acl| {
// The owner-authorized directory and its ancestors are pinned here.
// Commit provenance before any ACE mutation, including a partial grant.
if !record.runtime()?.metadata_roots.contains(&path) {
record.runtime_mut()?.metadata_roots.push(path.clone());
crate::installation_record::save_runtime(record)?;
}
if metadata_ace(acl, &sid)?.is_some() {
return Ok(false);
}
// Packaged Known Folder APIs verify these directory objects even when
// APPDATA is inherited. Do not grant listing, contents, or inheritance.
insert_metadata_ace(acl, &sid)?;
Ok(true)
})?;
}
Ok(())
}
/// Removes exact grants from all recorded roots before account deletion.
pub(crate) fn remove(token: HANDLE, record: &InstallationRecord) -> Result<()> {
let runtime = record.runtime()?;
let accounts = &runtime.accounts;
if accounts.is_empty() {
return Ok(());
}
let mut errors = Vec::new();
// Every grant records its root before changing permissions, including redirects.
for path in &runtime.metadata_roots {
let result = edit_root_acl(token, path, |acl| {
let mut changed = false;
for account in accounts {
let sid = LocalSid::from_string(&account.user_sid)?;
while let Some(index) = metadata_ace(acl, &sid)? {
unsafe { BOOL(security::DeleteAce(acl.as_mut_ptr().cast(), index)).ok() }
.context("remove AppData metadata ACE")?;
changed = true;
}
}
Ok(changed)
});
if let Err(error) = result
&& error
.downcast_ref::<io::Error>()
.is_none_or(|error| error.kind() != io::ErrorKind::NotFound)
{
errors.push(format!("{error:#}"));
}
}
ensure!(
errors.is_empty(),
"AppData metadata cleanup: {}",
errors.join("; ")
);
Ok(())
}
fn edit_root_acl(
token: HANDLE,
path: &Path,
edit: impl FnOnce(&mut Vec<u32>) -> Result<bool>,
) -> Result<()> {
let (_pins, handle) = with_owner_impersonation(token, || {
let mut pins = Vec::new();
crate::ipc::pin_existing_ancestors(path, &mut pins)?;
let handle = open_directory_no_reparse(
path,
filesystem::READ_CONTROL
| filesystem::WRITE_DAC
| filesystem::FILE_READ_ATTRIBUTES
| filesystem::FILE_TRAVERSE,
filesystem::FILE_SHARE_READ | filesystem::FILE_SHARE_WRITE,
DirectoryOpenDisposition::OpenExisting,
)?;
Ok((pins, handle))
})?;
let Some((mut acl, control)) = read_acl(&handle)? else {
return Ok(()); // A null DACL already allows metadata; never replace it.
};
if edit(&mut acl)? {
write_acl(&handle, &mut acl, control)?;
}
Ok(())
}
fn read_acl(handle: &OwnedHandle) -> Result<Option<(Vec<u32>, u16)>> {
let mut acl = ptr::null_mut();
let mut descriptor = ptr::null_mut();
let status = unsafe {
authorization::GetSecurityInfo(
handle.as_raw_handle() as _,
authorization::SE_FILE_OBJECT,
security::DACL_SECURITY_INFORMATION,
ptr::null_mut(),
ptr::null_mut(),
&mut acl,
ptr::null_mut(),
&mut descriptor,
)
};
if status != 0 {
return Err(io::Error::from_raw_os_error(status as i32)).context("read AppData DACL");
}
let result = (|| {
if acl.is_null() {
return Ok(None);
}
ensure!(
unsafe { security::IsValidAcl(acl) } != 0,
"invalid AppData DACL"
);
let length = unsafe { (*acl).AclSize } as usize;
let mut control = 0;
let mut revision = 0;
let queried = unsafe {
security::GetSecurityDescriptorControl(descriptor, &mut control, &mut revision)
};
BOOL(queried).ok().context("read AppData DACL control")?;
let mut buffer = vec![0u32; length.div_ceil(4)];
unsafe {
ptr::copy_nonoverlapping(acl.cast::<u8>(), buffer.as_mut_ptr().cast(), length);
}
Ok(Some((buffer, control)))
})();
unsafe { LocalFree(descriptor as _) };
result
}
fn metadata_ace(acl: &mut [u32], sid: &LocalSid) -> Result<Option<u32>> {
let acl = acl.as_mut_ptr().cast::<security::ACL>();
for index in 0..u32::from(unsafe { (*acl).AceCount }) {
let mut raw = ptr::null_mut();
unsafe { BOOL(security::GetAce(acl, index, &mut raw)).ok() }.context("read AppData ACE")?;
let offset = raw as usize - acl as usize;
ensure!(
offset + std::mem::size_of::<security::ACE_HEADER>()
<= usize::from(unsafe { (*acl).AclSize }),
"AppData ACE header exceeds ACL"
);
let header = unsafe { &*raw.cast::<security::ACE_HEADER>() };
ensure!(
offset + usize::from(header.AceSize) <= usize::from(unsafe { (*acl).AclSize }),
"AppData ACE exceeds ACL"
);
if header.AceType == 0 && header.AceFlags == 0 {
ensure!(header.AceSize >= 16, "AppData allow ACE is truncated");
let ace = unsafe { &*raw.cast::<security::ACCESS_ALLOWED_ACE>() };
let sid_pointer = ptr::addr_of!(ace.SidStart) as *mut std::ffi::c_void;
let subauthorities = unsafe { *sid_pointer.cast::<u8>().add(1) } as usize;
ensure!(
subauthorities <= 15 && 16 + subauthorities * 4 <= usize::from(header.AceSize),
"AppData allow ACE SID is truncated"
);
ensure!(
unsafe { security::IsValidSid(sid_pointer) } != 0,
"AppData allow ACE SID is invalid"
);
if ace.Mask == filesystem::FILE_READ_ATTRIBUTES
&& unsafe { security::EqualSid(sid_pointer, sid.as_ptr()) } != 0
{
return Ok(Some(index));
}
}
}
Ok(None)
}
fn insert_metadata_ace(acl: &mut Vec<u32>, sid: &LocalSid) -> Result<()> {
let capacity = usize::from(unsafe { (*acl.as_ptr().cast::<security::ACL>()).AclSize })
+ 8
+ unsafe { security::GetLengthSid(sid.as_ptr()) } as usize;
ensure!(capacity <= u16::MAX as usize, "AppData DACL is full");
acl.resize(capacity.div_ceil(4), 0);
unsafe { (*acl.as_mut_ptr().cast::<security::ACL>()).AclSize = capacity as u16 };
let mut single = [0u32; 32];
let single_acl = single.as_mut_ptr().cast();
unsafe {
BOOL(security::InitializeAcl(
single_acl,
std::mem::size_of_val(&single) as u32,
security::ACL_REVISION_DS,
))
.ok()
.context("initialize AppData metadata ACE")?;
BOOL(security::AddAccessAllowedAceEx(
single_acl,
security::ACL_REVISION_DS,
0,
filesystem::FILE_READ_ATTRIBUTES,
sid.as_ptr(),
))
.ok()
.context("construct AppData metadata ACE")?;
}
let mut added = ptr::null_mut();
unsafe { BOOL(security::GetAce(single_acl, 0, &mut added)).ok() }
.context("read new AppData metadata ACE")?;
let acl = acl.as_mut_ptr().cast::<security::ACL>();
let mut position = u32::from(unsafe { (*acl).AceCount });
for index in 0..position {
let mut raw = ptr::null_mut();
unsafe { BOOL(security::GetAce(acl, index, &mut raw)).ok() }
.context("read AppData ACE order")?;
if unsafe { (*raw.cast::<security::ACE_HEADER>()).AceFlags } & security::INHERITED_ACE as u8
!= 0
{
position = index;
break;
}
}
unsafe {
BOOL(security::AddAce(
acl,
security::ACL_REVISION_DS,
position,
added,
u32::from((*added.cast::<security::ACE_HEADER>()).AceSize),
))
.ok()
}
.context("insert AppData metadata ACE")?;
Ok(())
}
fn write_acl(handle: &OwnedHandle, acl: &mut [u32], control: u16) -> Result<()> {
let mut descriptor: security::SECURITY_DESCRIPTOR = unsafe { std::mem::zeroed() };
let preserved = security::SE_DACL_PROTECTED | security::SE_DACL_AUTO_INHERITED;
let raw_descriptor = (&mut descriptor as *mut security::SECURITY_DESCRIPTOR).cast();
unsafe {
BOOL(security::InitializeSecurityDescriptor(raw_descriptor, 1))
.ok()
.context("initialize AppData security descriptor")?;
BOOL(security::SetSecurityDescriptorDacl(
raw_descriptor,
1,
acl.as_mut_ptr().cast(),
i32::from(control & security::SE_DACL_DEFAULTED != 0),
))
.ok()
.context("set AppData DACL")?;
BOOL(security::SetSecurityDescriptorControl(
raw_descriptor,
preserved,
control & preserved,
))
.ok()
.context("preserve AppData DACL control")?;
}
// SetSecurityInfo recursively reapplies existing inheritable ACEs. The native
// object setter changes only this retained, owner-authorized directory handle.
let status = unsafe {
NtSetSecurityObject(
handle.as_raw_handle() as _,
security::DACL_SECURITY_INFORMATION,
&descriptor,
)
};
if status < 0 {
return Err(io::Error::from_raw_os_error(
unsafe { RtlNtStatusToDosError(status) } as i32,
))
.context("write AppData metadata DACL");
}
Ok(())
}
#[cfg(test)]
#[path = "metadata_tests.rs"]
mod tests;
@@ -0,0 +1,97 @@
//! Tests that metadata ACE edits preserve unrelated, inherited, and broader permissions.
use super::*;
fn ace_bytes(acl: &mut [u32]) -> Vec<Vec<u8>> {
let acl = acl.as_mut_ptr().cast::<security::ACL>();
(0..u32::from(unsafe { (*acl).AceCount }))
.map(|index| {
let mut raw = ptr::null_mut();
assert_ne!(unsafe { security::GetAce(acl, index, &mut raw) }, 0);
let length = unsafe { (*raw.cast::<security::ACE_HEADER>()).AceSize } as usize;
unsafe { std::slice::from_raw_parts(raw.cast::<u8>(), length) }.to_vec()
})
.collect()
}
fn existing_acl(sid: &LocalSid) -> Vec<u32> {
let mut acl = vec![0u32; 256];
let raw = acl.as_mut_ptr().cast();
assert_ne!(
unsafe { security::InitializeAcl(raw, 1024, security::ACL_REVISION_DS) },
0
);
assert_ne!(
unsafe {
security::AddAccessDeniedAceEx(
raw,
security::ACL_REVISION_DS,
0,
filesystem::FILE_WRITE_DATA,
sid.as_ptr(),
)
},
0
);
assert_ne!(
unsafe {
security::AddAccessAllowedAceEx(
raw,
security::ACL_REVISION_DS,
0,
filesystem::FILE_READ_DATA | filesystem::FILE_READ_ATTRIBUTES,
sid.as_ptr(),
)
},
0
);
assert_ne!(
unsafe {
security::AddAccessAllowedAceEx(
raw,
security::ACL_REVISION_DS,
security::INHERITED_ACE,
filesystem::FILE_READ_ATTRIBUTES,
sid.as_ptr(),
)
},
0
);
acl
}
#[test]
fn metadata_grant_and_cleanup_preserve_broader_and_inherited_aces() -> Result<()> {
let sid = LocalSid::from_string("S-1-5-21-1-2-3-1001")?;
let mut acl = existing_acl(&sid);
let before = ace_bytes(&mut acl);
assert_eq!(metadata_ace(&mut acl, &sid)?, None);
insert_metadata_ace(&mut acl, &sid)?;
assert_eq!(metadata_ace(&mut acl, &sid)?, Some(2));
assert_ne!(unsafe { security::IsValidAcl(acl.as_mut_ptr().cast()) }, 0);
assert_ne!(
unsafe { security::DeleteAce(acl.as_mut_ptr().cast(), 2) },
0
);
assert_eq!(ace_bytes(&mut acl), before);
Ok(())
}
#[test]
fn metadata_cleanup_handles_duplicates_without_touching_another_principal() -> Result<()> {
let sid = LocalSid::from_string("S-1-5-21-1-2-3-1001")?;
let other = LocalSid::from_string("S-1-5-21-1-2-3-1002")?;
let mut acl = existing_acl(&sid);
let before = ace_bytes(&mut acl);
insert_metadata_ace(&mut acl, &sid)?;
assert_eq!(metadata_ace(&mut acl, &other)?, None);
insert_metadata_ace(&mut acl, &sid)?;
while let Some(index) = metadata_ace(&mut acl, &sid)? {
assert_ne!(
unsafe { security::DeleteAce(acl.as_mut_ptr().cast(), index) },
0
);
}
assert_eq!(ace_bytes(&mut acl), before);
Ok(())
}
@@ -0,0 +1,146 @@
$ErrorActionPreference = 'Stop'
$PSModuleAutoLoadingPreference = 'None'
Import-Module -Name ($PSHOME + '\Modules\Microsoft.PowerShell.Utility\Microsoft.PowerShell.Utility.psd1') -ErrorAction Stop
Import-Module -Name ($PSHOME + '\Modules\Appx\Appx.psd1') -ErrorAction Stop
Import-Module -Name ($PSHOME + '\Modules\Microsoft.PowerShell.Management\Microsoft.PowerShell.Management.psd1') -ErrorAction Stop
if ([Security.Principal.WindowsIdentity]::GetCurrent().User.Value -ne 'S-1-5-18') { throw 'SYSTEM required' }
# Rust sends UTF-8 regardless of the machine's console code page. Keep one reader
# for the complete protocol so read-ahead cannot consume COMMIT or its EOF fence.
$protocolInput = [IO.StreamReader]::new([Console]::OpenStandardInput(), [Text.UTF8Encoding]::new($false, $true), $false)
$planJson = $protocolInput.ReadLine()
if (!$planJson) { return }
$plan = Microsoft.PowerShell.Utility\ConvertFrom-Json -InputObject $planJson
if ([string]::IsNullOrEmpty($plan.record.runtime.retiring)) { throw 'Retirement generation required' }
if (@($plan.targets).Count -gt 2) { throw 'Too many cleanup tokens' }
Add-Type -TypeDefinition @"
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
public static class CleanupNative {
[StructLayout(LayoutKind.Sequential, CharSet=CharSet.Unicode)] public struct Profile { public int size,flags; public string user,path,def,server,policy; public IntPtr handle; }
[StructLayout(LayoutKind.Sequential)] public struct Luid { public uint lo; public int hi; }
[StructLayout(LayoutKind.Sequential)] public struct Priv { public uint count; public Luid luid; public uint attributes; }
[DllImport("userenv.dll",CharSet=CharSet.Unicode,SetLastError=true)] public static extern bool LoadUserProfile(IntPtr t,ref Profile p);
[DllImport("userenv.dll",SetLastError=true)] public static extern bool UnloadUserProfile(IntPtr t,IntPtr p);
[DllImport("advapi32.dll",SetLastError=true)] public static extern bool ImpersonateLoggedOnUser(IntPtr t);
[DllImport("advapi32.dll",SetLastError=true)] public static extern bool RevertToSelf();
[DllImport("kernel32.dll")] public static extern bool CloseHandle(IntPtr h);
[DllImport("kernel32.dll")] public static extern IntPtr GetCurrentProcess();
[DllImport("advapi32.dll",SetLastError=true)] static extern bool OpenProcessToken(IntPtr p,uint a,out IntPtr t);
[DllImport("advapi32.dll",CharSet=CharSet.Unicode,SetLastError=true)] static extern bool LookupPrivilegeValue(string s,string n,out Luid l);
[DllImport("advapi32.dll",SetLastError=true)] static extern bool AdjustTokenPrivileges(IntPtr t,bool d,ref Priv p,int len,IntPtr old,IntPtr ret);
public static void Enable(string n) { IntPtr t; Luid l; if(!OpenProcessToken(GetCurrentProcess(),0x28,out t))throw new Win32Exception(); try { if(!LookupPrivilegeValue(null,n,out l))throw new Win32Exception(); Priv p=new Priv{count=1,luid=l,attributes=2}; if(!AdjustTokenPrivileges(t,false,ref p,0,IntPtr.Zero,IntPtr.Zero))throw new Win32Exception(); int e=Marshal.GetLastWin32Error(); if(e!=0)throw new Win32Exception(e); } finally { CloseHandle(t); } }
public static void Revert() { if(!RevertToSelf())Environment.FailFast("Cleanup impersonation revert failed"); }
}
"@
Add-Type -AssemblyName System.Runtime.WindowsRuntime
$null = [Windows.Management.Deployment.PackageManager,Windows.Management.Deployment,ContentType=WindowsRuntime]
$null = [Windows.Management.Deployment.DeploymentResult,Windows.Management.Deployment,ContentType=WindowsRuntime]
$null = [Windows.Management.Deployment.DeploymentProgress,Windows.Management.Deployment,ContentType=WindowsRuntime]
$asTask = [System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.IsGenericMethod -and $_.GetGenericArguments().Count -eq 2 -and $_.GetParameters().Count -eq 1 } | Select-Object -First 1
$await = $asTask.MakeGenericMethod([Windows.Management.Deployment.DeploymentResult],[Windows.Management.Deployment.DeploymentProgress])
$profiles = @()
try {
[CleanupNative]::Enable('SeBackupPrivilege')
[CleanupNative]::Enable('SeRestorePrivilege')
foreach ($target in $plan.targets) {
$token = [IntPtr][long]$target.handle
$identity = [Security.Principal.WindowsIdentity]::new($token)
try { if ($identity.User.Value -cne $target.sid) { throw 'Cleanup token SID mismatch' } }
finally { $identity.Dispose() }
$profile = New-Object CleanupNative+Profile
$profile.size = [Runtime.InteropServices.Marshal]::SizeOf($profile)
$profile.flags = 1
$profile.user = $target.username
if (![CleanupNative]::LoadUserProfile($token, [ref]$profile)) { throw [ComponentModel.Win32Exception]::new([Runtime.InteropServices.Marshal]::GetLastWin32Error()) }
$profiles += @{ token = $token; profile = $profile; sid = $target.sid }
}
[Console]::Out.WriteLine('READY')
# EOF without a successful native-cleanup commit only unloads profiles.
if ($protocolInput.ReadLine() -cne 'COMMIT') { return }
# Even after COMMIT, removal must wait until the service releases its own package.
if ($protocolInput.ReadToEnd().Length -ne 0) { return }
$finished = $false
while (!$finished) {
$mutex = $null; $locked = $false; $key = $null; $legacy = $null
try {
$security = [Security.AccessControl.MutexSecurity]::new()
$security.SetSecurityDescriptorSddlForm('D:P(A;;GA;;;SY)(A;;GA;;;BA)')
$created = $false
$mutex = [Threading.Mutex]::new($false, 'Global\CodexSandboxSetup', [ref]$created, $security)
try { $locked = $mutex.WaitOne(30000) }
catch [Threading.AbandonedMutexException] { $locked = $true }
if (!$locked) { throw 'Sandbox setup is still active' }
$key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey($plan.key, $true)
if (!$key) { return }
$record = Microsoft.PowerShell.Utility\ConvertFrom-Json -InputObject ([string]$key.GetValue($plan.value))
if ($record.runtime.retiring -ne $plan.record.runtime.retiring -or
$record.user_sid -ne $plan.record.user_sid -or
$record.codex_home -ne $plan.record.codex_home -or
$record.runtime.package_family -ne $plan.record.runtime.package_family) { return }
foreach ($entry in $profiles) {
foreach ($package in @(Appx\Get-AppxPackage -User $entry.sid -ErrorAction Stop)) {
if ($package.PackageFamilyName -ne $record.runtime.package_family) { continue }
if (![CleanupNative]::ImpersonateLoggedOnUser($entry.token)) { throw 'Cleanup impersonation failed' }
try {
if ([Security.Principal.WindowsIdentity]::GetCurrent().User.Value -cne $entry.sid) { throw 'Cleanup identity changed' }
# Construct and submit synchronously under the retained user, not SYSTEM.
$manager = [Windows.Management.Deployment.PackageManager]::new()
$operation = $manager.RemovePackageAsync($package.PackageFullName)
} finally { [CleanupNative]::Revert() }
# Failure to obtain a waiter is not proof the operation stopped.
$task = $null
while (!$task) {
try { $task = $await.Invoke($null, @($operation)) }
catch { Microsoft.PowerShell.Utility\Start-Sleep -Seconds 1 }
}
while (!$task.IsCompleted) { Microsoft.PowerShell.Utility\Start-Sleep -Milliseconds 50 }
$result = $task.GetAwaiter().GetResult()
if ($result.ExtendedErrorCode -and $result.ExtendedErrorCode.HResult -ne 0) { throw $result.ErrorText }
}
}
foreach ($account in $plan.record.runtime.accounts) {
foreach ($package in @(Appx\Get-AppxPackage -User $account.user_sid -ErrorAction Stop)) {
if ($package.PackageFamilyName -eq $record.runtime.package_family) { throw 'Runtime registration remains' }
}
}
$legacy = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey($plan.legacy_key, $true)
if (!$legacy) { throw 'Installation parent is missing' }
$legacyJson = $legacy.GetValue($plan.value)
if ($legacyJson) {
$owner = Microsoft.PowerShell.Utility\ConvertFrom-Json -InputObject ([string]$legacyJson)
if ($owner.user_sid -eq $plan.record.user_sid -and $owner.codex_home -eq $plan.record.codex_home) { $legacy.DeleteValue($plan.value, $false) }
}
$key.Dispose(); $key = $null
$legacy.Flush()
[Microsoft.Win32.Registry]::LocalMachine.DeleteSubKey($plan.key, $false)
$finished = $true
} catch { [Console]::Error.WriteLine("Runtime package removal deferred: $_") }
finally {
if ($key) { $key.Dispose() }
if ($legacy) { $legacy.Dispose() }
if ($locked) { $mutex.ReleaseMutex() }
if ($mutex) { $mutex.Dispose() }
}
if (!$finished) { Microsoft.PowerShell.Utility\Start-Sleep -Seconds 30 }
}
} finally {
foreach ($entry in $profiles) {
while (![CleanupNative]::UnloadUserProfile($entry.token, $entry.profile.handle)) {
Microsoft.PowerShell.Utility\Start-Sleep -Seconds 1
}
}
foreach ($target in $plan.targets) { $null = [CleanupNative]::CloseHandle([IntPtr][long]$target.handle) }
}
# A reinstall may have tried to start the service while the retirement fence blocked it.
while ($true) {
try {
foreach ($package in @(Appx\Get-AppxPackage -User $plan.record.user_sid -ErrorAction Stop)) {
if ($package.PackageFamilyName -eq $plan.record.runtime.package_family) {
Microsoft.PowerShell.Management\Start-Service -Name $plan.service_name -ErrorAction Stop
break
}
}
break
} catch { Microsoft.PowerShell.Utility\Start-Sleep -Seconds 30 }
}
@@ -0,0 +1,215 @@
//! Prepare exact managed logons before native cleanup; authorize removal only after success.
//! The child owns loaded profiles and waits for both COMMIT and service-process EOF.
use std::io::Read;
use std::io::Write;
use std::os::windows::fs::MetadataExt;
use std::os::windows::io::AsHandle;
use std::os::windows::io::AsRawHandle;
use std::os::windows::io::BorrowedHandle;
use std::os::windows::io::OwnedHandle;
use std::os::windows::process::CommandExt;
use std::path::PathBuf;
use std::process::Child;
use std::process::Command;
use std::process::Stdio;
use std::time::Duration;
use std::time::Instant;
use anyhow::Context;
use anyhow::Result;
use anyhow::ensure;
use base64::Engine;
use windows_sys::Win32::Foundation::DUPLICATE_SAME_ACCESS;
use windows_sys::Win32::Foundation::DuplicateHandle;
use windows_sys::Win32::Foundation::HANDLE;
use windows_sys::Win32::System::Pipes::PeekNamedPipe;
use windows_sys::Win32::System::SystemInformation::GetSystemDirectoryW;
use windows_sys::Win32::System::Threading as threading;
use crate::installation_record::InstallationRecord;
use crate::package_lifecycle::with_owner_impersonation;
pub(crate) struct PreparedRemoval {
child: Child,
tokens: Vec<OwnedHandle>,
}
impl PreparedRemoval {
pub(crate) fn tokens(&self) -> Vec<BorrowedHandle<'_>> {
self.tokens.iter().map(AsHandle::as_handle).collect()
}
/// Called only after the native cleanup guard has successfully finished.
pub(crate) fn commit(mut self) -> Result<()> {
self.child
.stdin
.as_mut()
.context("cleanup input closed")?
.write_all(b"COMMIT\n")
.context("commit Windows package cleanup")?;
// The only pipe writer remains open until this own-process service exits.
// Dropping an uncommitted preparation instead sends EOF, which only aborts.
std::mem::forget(self.child);
Ok(())
}
}
// The cleanup entry checked the service family; prepare_cleanup validated this record
// under the same setup lock. Fresh logons below still require exact account-SID checks.
pub(crate) fn prepare(owner_token: HANDLE, record: &InstallationRecord) -> Result<PreparedRemoval> {
ensure!(
record.runtime()?.retiring.is_some(),
"runtime is not retiring"
);
let mut tokens = Vec::new();
let mut targets = Vec::new();
for account in &record.runtime()?.accounts {
if codex_windows_sandbox::local_user_flags(account.account.username())?.is_none() {
ensure!(
super::registered_packages(&account.user_sid, &record.runtime()?.package_family)?
.is_empty(),
"runtime account is missing while its package remains registered"
);
continue;
}
let token = with_owner_impersonation(owner_token, || {
let mut pins = Vec::new();
crate::ipc::pin_existing_ancestors(
&codex_windows_sandbox::sandbox_secrets_dir(&record.codex_home),
&mut pins,
)?;
codex_windows_sandbox::logon_existing_sandbox_account(
&record.codex_home,
account.account,
)
})?;
super::validate_target(
token.as_raw_handle() as _,
account.account,
Some(&account.user_sid),
)?;
targets.push(serde_json::json!({
"username": account.account.username(),
"sid": account.user_sid,
}));
tokens.push(token);
}
let mut system = [0u16; 32768];
let length = unsafe { GetSystemDirectoryW(system.as_mut_ptr(), system.len() as u32) } as usize;
ensure!(
length > 0 && length < system.len(),
"Windows system directory is unavailable"
);
let system = PathBuf::from(String::from_utf16(&system[..length])?);
let executable = system.join(r"WindowsPowerShell\v1.0\powershell.exe");
let metadata = executable
.symlink_metadata()
.context("inspect Windows PowerShell")?;
ensure!(
metadata.is_file()
&& metadata.file_attributes()
& windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT
== 0,
"invalid Windows PowerShell executable"
);
let bytes = include_str!("removal.ps1")
.encode_utf16()
.flat_map(u16::to_le_bytes)
.collect::<Vec<_>>();
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
ensure!(
executable.as_os_str().len() + encoded.len() + 64 < 32767,
"cleanup command exceeds the Windows command-line limit"
);
let mut child = Command::new(executable)
.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-EncodedCommand",
&encoded,
])
.current_dir(system)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.creation_flags(threading::CREATE_NO_WINDOW)
.spawn()
.context("start Windows package cleanup")?;
// Do not relax process handle inheritance. Only these validated token handles
// are duplicated into the exact child process, with inheritance disabled.
for (target, token) in targets.iter_mut().zip(&tokens) {
let mut remote = 0;
let duplicated = unsafe {
DuplicateHandle(
threading::GetCurrentProcess(),
token.as_raw_handle() as _,
child.as_raw_handle() as _,
&mut remote,
/*dwdesiredaccess*/ 0,
/*binherithandle*/ 0,
DUPLICATE_SAME_ACCESS,
)
};
ensure!(
duplicated != 0,
"duplicate managed cleanup token: {}",
std::io::Error::last_os_error()
);
target["handle"] = serde_json::json!(remote as usize);
}
let plan = serde_json::json!({
"key": crate::installation_record::CORE_INSTALLATION_KEY,
"legacy_key": crate::installation_record::INSTALLATION_KEY,
"value": crate::installation_record::INSTALLATION_VALUE,
"record": record,
"targets": targets,
"service_name": codex_windows_sandbox::windows_sandbox_service_name()?,
});
let input = child.stdin.as_mut().context("capture cleanup input")?;
serde_json::to_writer(&mut *input, &plan)?;
input.write_all(b"\n")?;
let output = child.stdout.as_mut().context("capture cleanup readiness")?;
let deadline = Instant::now() + Duration::from_secs(30);
let mut ready = Vec::new();
while !ready.ends_with(b"\n") {
ensure!(
Instant::now() < deadline,
"timed out preparing Windows package cleanup"
);
let mut available = 0;
let result = unsafe {
PeekNamedPipe(
output.as_raw_handle() as _,
std::ptr::null_mut(),
/*nbuffersize*/ 0,
std::ptr::null_mut(),
&mut available,
std::ptr::null_mut(),
)
};
ensure!(
result != 0,
"cleanup exited before readiness: {}",
std::io::Error::last_os_error()
);
if available == 0 {
std::thread::sleep(Duration::from_millis(20));
continue;
}
let mut byte = [0];
output.read_exact(&mut byte)?;
ready.push(byte[0]);
ensure!(ready.len() <= 16, "invalid cleanup readiness response");
}
ensure!(
ready == b"READY\r\n" || ready == b"READY\n",
"cleanup profiles were not prepared"
);
Ok(PreparedRemoval { child, tokens })
}
#[cfg(test)]
#[path = "removal_tests.rs"]
mod tests;
@@ -0,0 +1,106 @@
//! Exercise the actual finalizer authorization barrier without account or package mutations.
use std::io::BufRead;
use std::io::BufReader;
use std::io::Write;
use std::os::windows::io::AsRawHandle;
use std::os::windows::process::CommandExt;
use std::path::PathBuf;
use std::process::Command;
use std::process::Stdio;
use anyhow::Context;
use anyhow::Result;
use anyhow::ensure;
use pretty_assertions::assert_eq;
use windows_sys::Win32::Foundation::WAIT_OBJECT_0;
use windows_sys::Win32::Foundation::WAIT_TIMEOUT;
use windows_sys::Win32::System::SystemInformation::GetSystemDirectoryW;
use windows_sys::Win32::System::Threading::CREATE_NO_WINDOW;
use windows_sys::Win32::System::Threading::WaitForSingleObject;
#[test]
fn finalizer_preserves_utf8_plan_and_requires_commit_and_stdin_eof() -> Result<()> {
let mut system = [0u16; 32768];
let length = unsafe { GetSystemDirectoryW(system.as_mut_ptr(), system.len() as u32) } as usize;
ensure!(
length > 0 && length < system.len(),
"Windows system directory is unavailable"
);
let executable = PathBuf::from(String::from_utf16(&system[..length])?)
.join(r"WindowsPowerShell\v1.0\powershell.exe");
// Run the production decoder and protocol in isolation, never the cleanup body.
let script = include_str!("removal.ps1");
let input_anchor = "throw 'SYSTEM required' }";
let input_start = script.find(input_anchor).context("find input setup")? + input_anchor.len();
let input_end = script
.find("if ([string]::IsNullOrEmpty")
.context("find plan validation")?;
let input_setup = &script[input_start..input_end];
let start = script
.find(" # EOF without a successful native-cleanup commit")
.context("find commit barrier")?;
let end = script[start..]
.find(" $finished = $false")
.context("find cleanup boundary")?
+ start;
let home = r"C:\Users\Zoë-東京\.codex";
let script = format!(
"$ErrorActionPreference = 'Stop';
[Console]::InputEncoding = [Text.Encoding]::GetEncoding(437);
{input_setup}
if ($plan.record.codex_home -cne '{home}') {{ throw 'UTF-8 plan was corrupted' }}
[Console]::Out.WriteLine('waiting');\n{}\n[Console]::Out.WriteLine('released')",
&script[start..end]
);
let mut plan = serde_json::to_vec(&serde_json::json!({ "record": { "codex_home": home } }))?;
plan.push(b'\n');
for (input, expected) in [
("", ""),
("ABORT\n", ""),
("COMMIT\n", "released"),
("COMMIT\ntrailing", ""),
] {
let mut child = Command::new(&executable)
.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
&script,
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.creation_flags(CREATE_NO_WINDOW)
.spawn()?;
// Batch the plan and command so decoder read-ahead also exercises the shared reader.
let mut request = plan.clone();
request.extend_from_slice(input.as_bytes());
child
.stdin
.as_mut()
.context("capture barrier input")?
.write_all(&request)?;
let mut output = BufReader::new(child.stdout.take().context("capture barrier output")?);
let mut line = String::new();
output.read_line(&mut line)?;
assert_eq!(line.trim_end(), "waiting");
if input.is_empty() || input.starts_with("COMMIT\n") {
let before_eof = unsafe {
WaitForSingleObject(child.as_raw_handle() as _, /*dwmilliseconds*/ 500)
};
assert_eq!(before_eof, WAIT_TIMEOUT);
}
drop(child.stdin.take());
let after_eof = unsafe {
WaitForSingleObject(child.as_raw_handle() as _, /*dwmilliseconds*/ 10_000)
};
assert_eq!(after_eof, WAIT_OBJECT_0);
line.clear();
output.read_line(&mut line)?;
assert_eq!(line.trim_end(), expected);
ensure!(child.wait()?.success(), "barrier subprocess failed");
}
Ok(())
}
+25 -24
View File
@@ -1,8 +1,7 @@
//! Windows service lifecycle and event-log integration for sandbox provisioning.
//! Owns Windows SCM registration, service state, and sandbox provisioning event logs.
use std::ffi::c_void;
use std::io;
use std::mem::size_of;
use std::ptr;
use std::sync::Arc;
use std::sync::OnceLock;
@@ -20,7 +19,6 @@ use windows_sys::Win32::System::EventLog::EVENTLOG_ERROR_TYPE;
use windows_sys::Win32::System::EventLog::EVENTLOG_INFORMATION_TYPE;
use windows_sys::Win32::System::EventLog::RegisterEventSourceW;
use windows_sys::Win32::System::EventLog::ReportEventW;
use windows_sys::Win32::System::RemoteDesktop::WTSSESSION_NOTIFICATION;
use windows_sys::Win32::System::Services::RegisterServiceCtrlHandlerExW;
use windows_sys::Win32::System::Services::SERVICE_ACCEPT_SESSIONCHANGE;
use windows_sys::Win32::System::Services::SERVICE_ACCEPT_SHUTDOWN;
@@ -42,7 +40,8 @@ use windows_sys::Win32::System::Services::StartServiceCtrlDispatcherW;
mod runtime_lifecycle;
pub(crate) const SERVICE_NAME: &str = "CodexSandboxService";
pub(crate) use runtime_lifecycle::retry_cleanup;
const EVENT_SERVICE_STARTED: u32 = 1000;
const EVENT_SERVICE_STOP_REQUESTED: u32 = 1001;
const EVENT_SERVICE_STOPPED: u32 = 1002;
@@ -57,28 +56,31 @@ const MAX_EVENT_MESSAGE_UNITS: usize = 1024;
static SERVICE_STATE: OnceLock<ServiceState> = OnceLock::new();
struct ServiceState {
service_name: String,
pipe_name: String,
shutdown: Arc<AtomicBool>,
uninstalling: Arc<AtomicBool>,
status_handle: OnceLock<SERVICE_STATUS_HANDLE>,
current_status: AtomicU32,
changed_session: AtomicU32,
stop_requested: AtomicBool,
}
pub(crate) fn run() -> Result<()> {
let service_name = codex_windows_sandbox::windows_sandbox_service_name()?;
let state = ServiceState {
service_name: service_name.clone(),
pipe_name: codex_windows_sandbox::windows_sandbox_service_pipe_name()?,
shutdown: Arc::new(AtomicBool::new(false)),
uninstalling: Arc::new(AtomicBool::new(false)),
status_handle: OnceLock::new(),
current_status: AtomicU32::new(SERVICE_START_PENDING),
changed_session: AtomicU32::new(u32::MAX),
stop_requested: AtomicBool::new(false),
};
SERVICE_STATE
.set(state)
.map_err(|_| anyhow::anyhow!("the service dispatcher was already initialized"))?;
let mut service_name = SERVICE_NAME
let mut service_name = service_name
.encode_utf16()
.chain(std::iter::once(0))
.collect::<Vec<_>>();
@@ -108,10 +110,14 @@ pub(crate) fn run_foreground() -> Result<()> {
crate::ipc::run(
Arc::new(AtomicBool::new(false)),
|| {
eprintln!("{SERVICE_NAME} listening on {}", crate::ipc::PIPE_NAME);
eprintln!(
"{} listening on {}",
codex_windows_sandbox::windows_sandbox_service_name()?,
codex_windows_sandbox::windows_sandbox_service_pipe_name()?
);
Ok(())
},
|installation, _| Ok(installation),
runtime_lifecycle::foreground_owner,
|| Ok(()),
)
}
@@ -126,7 +132,7 @@ unsafe extern "system" fn service_main(_argument_count: u32, _arguments: *mut *m
EVENT_SERVICE_FAILED,
&format!("The Codex sandbox service encountered a fatal error: {error:#}"),
);
eprintln!("{SERVICE_NAME} failed: {error:#}");
eprintln!("{} failed: {error:#}", state.service_name);
if state.status_handle.get().is_some() {
let _ = state.report_status(SERVICE_STOPPED, ERROR_SERVICE_SPECIFIC_ERROR);
}
@@ -134,7 +140,8 @@ unsafe extern "system" fn service_main(_argument_count: u32, _arguments: *mut *m
}
fn service_main_inner(state: &ServiceState) -> Result<()> {
let service_name = SERVICE_NAME
let service_name = state
.service_name
.encode_utf16()
.chain(std::iter::once(0))
.collect::<Vec<_>>();
@@ -169,7 +176,7 @@ fn service_main_inner(state: &ServiceState) -> Result<()> {
unsafe extern "system" fn service_control_handler(
control: u32,
_event_type: u32,
event_data: *mut c_void,
_event_data: *mut c_void,
_context: *mut c_void,
) -> u32 {
let Some(state) = SERVICE_STATE.get() else {
@@ -205,25 +212,17 @@ unsafe extern "system" fn service_control_handler(
NO_ERROR
}
SERVICE_CONTROL_SESSIONCHANGE => {
if !event_data.is_null() {
let event = unsafe { &*event_data.cast::<WTSSESSION_NOTIFICATION>() };
if event.cbSize as usize >= size_of::<WTSSESSION_NOTIFICATION>() {
state
.changed_session
.store(event.dwSessionId, Ordering::Release);
wake_listener();
}
}
wake_listener();
NO_ERROR
}
_ => ERROR_CALL_NOT_IMPLEMENTED,
}
}
fn wake_listener() {
pub(crate) fn wake_listener() {
if let Some(state) = SERVICE_STATE.get() {
std::thread::spawn(move || {
crate::ipc::wake(crate::ipc::PIPE_NAME, || {
crate::ipc::wake(&state.pipe_name, || {
state.current_status.load(Ordering::Acquire) == SERVICE_STOPPED
});
});
@@ -239,7 +238,9 @@ pub(crate) fn log_error(event_id: u32, message: &str) {
}
fn log_event(event_type: u16, event_id: u32, message: &str) {
let source = SERVICE_NAME
let source = SERVICE_STATE
.get()
.map_or("CodexSandboxService", |state| state.service_name.as_str())
.encode_utf16()
.chain(std::iter::once(0))
.collect::<Vec<_>>();
@@ -1,5 +1,7 @@
//! Connects the provisioning broker to the existing package owner and uninstall listener.
//! Runs owner-aware cleanup in the live provisioning broker before further admission.
//! Shutdown means SCM stop until cleanup finishes; retirement fences are never replayed.
use std::cell::Cell;
use std::sync::Arc;
use std::sync::atomic::Ordering;
@@ -7,54 +9,141 @@ use anyhow::Context;
use anyhow::Result;
use windows_sys::Win32::Foundation::NO_ERROR;
use windows_sys::Win32::System::Services::SERVICE_RUNNING;
use windows_sys::Win32::System::Services::SERVICE_STOP_PENDING;
use super::EVENT_SERVICE_FAILED;
use super::EVENT_SERVICE_STARTED;
use super::SERVICE_STATE;
use super::ServiceState;
use super::log_error;
use super::log_information;
use crate::package_lifecycle::PackageLifecycle;
/// Foreground debugging has no persistent watcher, but must retain the same admission rules.
#[cfg(debug_assertions)]
pub(super) fn foreground_owner(
mut record: crate::installation_record::InstallationRecord,
_token: crate::ipc::OwnedHandle,
runtime: codex_windows_sandbox::SetupRuntime,
) -> Result<crate::installation_record::InstallationRecord> {
let _lock = codex_windows_sandbox::acquire_sandbox_setup_lock(/*timeout_ms*/ 5_000)?;
if let Some(previous) = crate::installation_record::load()? {
let same_owner =
previous.user_sid == record.user_sid && previous.codex_home == record.codex_home;
if previous.runtime.is_some() {
let family = windows::ApplicationModel::Package::Current()?
.Id()?
.FamilyName()?;
previous.admit_owner(&record, &family.to_string())?;
record.runtime = previous.runtime;
} else if runtime == codex_windows_sandbox::SetupRuntime::Registered {
anyhow::ensure!(
same_owner,
"shared sandbox resources belong to a different owner"
);
}
if same_owner {
record.desktop_installation = previous
.desktop_installation
.or(record.desktop_installation);
}
}
Ok(record)
}
pub(super) fn run(state: &ServiceState, package_lifecycle: &PackageLifecycle) -> Result<()> {
let cleaned = Cell::new(false);
let restore_owner = || {
let restored = crate::installation_record::load().and_then(|record| {
record.map_or(Ok(()), |record| {
package_lifecycle.restore_logged_in_owner(record.session_id)
})
});
if let Err(error) = restored {
log_error(
EVENT_SERVICE_FAILED,
&format!("unable to restore package uninstall listener: {error:#}"),
);
}
};
crate::ipc::run(
Arc::clone(&state.shutdown),
|| {
restore_owner();
state.report_status(SERVICE_RUNNING, NO_ERROR)?;
if let Some(record) = crate::installation_record::load()?
&& let Err(error) = package_lifecycle.restore_logged_in_owner(record.session_id)
{
log_error(
EVENT_SERVICE_FAILED,
&format!("unable to restore package uninstall listener: {error:#}"),
);
}
log_information(
EVENT_SERVICE_STARTED,
"The Codex sandbox service is running.",
);
Ok(())
},
|installation, user_token| {
|installation, user_token, _runtime| {
package_lifecycle.register_authenticated_user(installation, user_token)
},
|| {
let session = state.changed_session.swap(u32::MAX, Ordering::AcqRel);
if session == u32::MAX {
|| loop {
restore_owner();
if !crate::package_lifecycle::runtime_owner_removed()? {
return Ok(());
}
if let Err(error) = package_lifecycle.restore_authenticated_user(session) {
log_error(
EVENT_SERVICE_FAILED,
&format!("unable to restore package uninstall listener: {error:#}"),
);
let Err(error) = package_lifecycle.clean_up() else {
cleaned.set(true);
state.shutdown.store(true, Ordering::Release);
return state.report_status(SERVICE_STOP_PENDING, NO_ERROR);
};
if crate::installation_record::load_runtime()?.is_none_or(|record| {
record
.runtime
.is_some_and(|runtime| runtime.retiring.is_some())
}) {
return Err(error);
}
log_error(
EVENT_SERVICE_FAILED,
&format!("sandbox cleanup deferred: {error:#}"),
);
// Keep the pipe available during pre-fence backoff. A reinstall
// resumes admission on the next owner check; SCM stops are never reset.
if !wait_for_cleanup_retry(state) {
return Err(error);
}
Ok(())
},
)
.context("run the sandbox provisioning broker")?;
if state.stop_requested.load(Ordering::Acquire) && state.uninstalling.load(Ordering::Acquire) {
if !cleaned.get()
&& state.stop_requested.load(Ordering::Acquire)
&& (state.uninstalling.load(Ordering::Acquire)
|| crate::package_lifecycle::runtime_owner_removed()?)
{
package_lifecycle.clean_up()?;
}
Ok(())
}
/// Retries the current teardown step, never a phase recovered from stored intent.
pub(crate) fn retry_cleanup(mut operation: impl FnMut() -> Result<()>) -> Result<()> {
loop {
let Err(error) = operation() else {
return Ok(());
};
let Some(state) = SERVICE_STATE.get() else {
return Err(error);
};
log_error(
EVENT_SERVICE_FAILED,
&format!("sandbox cleanup deferred: {error:#}"),
);
if !wait_for_cleanup_retry(state) {
return Err(error);
}
}
}
fn wait_for_cleanup_retry(state: &ServiceState) -> bool {
for _ in 0..30 {
if state.shutdown.load(Ordering::Acquire) {
return false;
}
std::thread::sleep(std::time::Duration::from_secs(1));
}
!state.shutdown.load(Ordering::Acquire)
}