Compare commits

...

3 Commits

Author SHA1 Message Date
celia-oai
5cce1f7345 changes 2026-03-27 16:49:23 -07:00
celia-oai
4222b6978f changes 2026-03-27 13:43:27 -07:00
Michael Bolin
95845cf6ce fix: disable plugins in SDK integration tests (#16036)
## Why

The TypeScript SDK tests create a fresh `CODEX_HOME` for each Jest case
and delete it during teardown. That cleanup has been flaking because the
real `codex` binary can still be doing background curated-plugin startup
sync under `.tmp/plugins-clone-*`, which races the test harness's
recursive delete and leaves `ENOTEMPTY` failures behind.

This path is unrelated to what the SDK tests are exercising, so letting
plugin startup run during these tests only adds nondeterministic
filesystem activity. This showed up recently in the `sdk` CI lane for
[#16031](https://github.com/openai/codex/pull/16031).

## What Changed

- updated `sdk/typescript/tests/testCodex.ts` to merge test config
through a single helper
- disabled `features.plugins` unconditionally for SDK integration tests
so the CLI does not start curated-plugin sync in the temporary
`CODEX_HOME`
- preserved other explicit feature overrides from individual tests while
forcing `plugins` back to `false`
- kept the existing mock-provider override behavior intact for
SSE-backed tests

## Verification

- `pnpm test --runInBand`
- `pnpm lint`
2026-03-27 13:04:34 -07:00
7 changed files with 532 additions and 134 deletions

View File

@@ -586,13 +586,14 @@ fn permissions_profiles_require_default_permissions() -> std::io::Result<()> {
}
#[test]
fn permissions_profiles_reject_writes_outside_workspace_root() -> std::io::Result<()> {
fn permissions_profiles_allow_writes_outside_workspace_root_with_read_only_legacy_fallback()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let external_write_path = if cfg!(windows) { r"C:\temp" } else { "/tmp" };
let err = Config::load_from_base_config_with_overrides(
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("workspace".to_string()),
permissions: Some(PermissionsToml {
@@ -616,14 +617,45 @@ fn permissions_profiles_reject_writes_outside_workspace_root() -> std::io::Resul
..Default::default()
},
codex_home.path().to_path_buf(),
)
.expect_err("writes outside the workspace root should be rejected");
)?;
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
config.permissions.sandbox_policy.get(),
&SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::Restricted {
include_platform_defaults: false,
readable_roots: Vec::new(),
},
network_access: false,
}
);
assert!(
err.to_string()
.contains("filesystem writes outside the workspace root"),
"{err}"
config
.permissions
.file_system_sandbox_policy
.can_write_path_with_cwd(Path::new(external_write_path), cwd.path())
);
assert!(
config
.permissions
.file_system_sandbox_policy
.needs_direct_runtime_enforcement(
config.permissions.network_sandbox_policy,
cwd.path(),
)
);
assert!(
config
.permissions
.file_system_sandbox_policy
.can_write_path_with_cwd(codex_home.path().join("memories").as_path(), cwd.path())
);
assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User);
let warnings = &config.startup_warnings;
assert!(
warnings.is_empty(),
"external writes should no longer fail config load: {warnings:?}"
);
Ok(())
}
@@ -734,6 +766,133 @@ fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<()> {
Ok(())
}
#[test]
fn permissions_profiles_allow_tmpdir_write_with_read_only_legacy_fallback() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("workspace".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([(
":tmpdir".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Write),
)]),
}),
network: None,
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)?;
let memories_root = codex_home.path().join("memories").abs();
assert!(
config
.permissions
.file_system_sandbox_policy
.entries
.iter()
.any(|entry| {
entry.access == FileSystemAccessMode::Write
&& entry.path
== FileSystemPath::Special {
value: FileSystemSpecialPath::Tmpdir,
}
})
);
assert!(
config
.permissions
.file_system_sandbox_policy
.can_write_path_with_cwd(memories_root.as_path(), cwd.path())
);
assert_eq!(
config.permissions.sandbox_policy.get(),
&SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::Restricted {
include_platform_defaults: false,
readable_roots: Vec::new(),
},
network_access: false,
}
);
Ok(())
}
#[cfg(unix)]
#[test]
fn permissions_profiles_allow_slash_tmp_write_with_read_only_legacy_fallback() -> std::io::Result<()>
{
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?;
let config = Config::load_from_base_config_with_overrides(
ConfigToml {
default_permissions: Some("workspace".to_string()),
permissions: Some(PermissionsToml {
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
filesystem: Some(FilesystemPermissionsToml {
entries: BTreeMap::from([(
"/tmp".to_string(),
FilesystemPermissionToml::Access(FileSystemAccessMode::Write),
)]),
}),
network: None,
},
)]),
}),
..Default::default()
},
ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
},
codex_home.path().to_path_buf(),
)?;
assert!(
config
.permissions
.file_system_sandbox_policy
.entries
.iter()
.any(|entry| {
entry.access == FileSystemAccessMode::Write
&& entry.path
== FileSystemPath::Path {
path: test_absolute_path("/tmp"),
}
})
);
assert_eq!(
config.permissions.sandbox_policy.get(),
&SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::Restricted {
include_platform_defaults: false,
readable_roots: Vec::new(),
},
network_access: false,
}
);
Ok(())
}
#[test]
fn permissions_profiles_allow_unknown_special_paths_with_nested_entries() -> std::io::Result<()> {
let config = load_workspace_permission_profile(PermissionProfileToml {

View File

@@ -2154,17 +2154,18 @@ impl Config {
default_permissions,
&mut startup_warnings,
)?;
let mut sandbox_policy = file_system_sandbox_policy
.to_legacy_sandbox_policy(network_sandbox_policy, resolved_cwd.as_path())?;
if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) {
if !file_system_sandbox_policy
.get_writable_roots_with_cwd(resolved_cwd.as_path())
.is_empty()
{
file_system_sandbox_policy = file_system_sandbox_policy
.with_additional_writable_roots(
resolved_cwd.as_path(),
&additional_writable_roots,
);
sandbox_policy = file_system_sandbox_policy
.to_legacy_sandbox_policy(network_sandbox_policy, resolved_cwd.as_path())?;
}
let sandbox_policy = file_system_sandbox_policy
.to_legacy_sandbox_policy(network_sandbox_policy, resolved_cwd.as_path())?;
(
configured_network_proxy_config,
sandbox_policy,

View File

@@ -83,7 +83,20 @@ pub enum FileSystemSpecialPath {
#[ts(optional)]
subpath: Option<PathBuf>,
},
/// Config-facing `:tmpdir` special path.
///
/// This is the broader restricted-policy temp bundle used by filesystem
/// permission profiles. On Unix it expands to the temp env roots plus
/// `/tmp` and `/tmp`'s canonical target. On Windows it expands to the
/// platform temp env roots.
Tmpdir,
/// Legacy env-backed temp roots used when bridging old
/// `SandboxPolicy::WorkspaceWrite` semantics into split filesystem policy.
///
/// Keep this narrower than [`FileSystemSpecialPath::Tmpdir`] so existing
/// `exclude_tmpdir_env_var = false` behavior does not silently widen to the
/// full temp bundle on Unix.
TmpdirEnvVar,
SlashTmp,
/// WARNING: `:special_path` tokens are part of config compatibility.
/// Do not make older runtimes reject newly introduced tokens.
@@ -644,50 +657,57 @@ impl FileSystemSandboxPolicy {
FileSystemSpecialPath::CurrentWorkingDirectory => {
if entry.access.can_write() {
workspace_root_writable = true;
} else if entry.access.can_read()
&& let Some(path) = resolve_file_system_special_path(
} else if entry.access.can_read() {
readable_roots.extend(resolve_file_system_special_paths(
value,
cwd_absolute.as_ref(),
)
{
readable_roots.push(path);
));
}
}
FileSystemSpecialPath::ProjectRoots { subpath } => {
if subpath.is_none() && entry.access.can_write() {
workspace_root_writable = true;
} else if let Some(path) =
resolve_file_system_special_path(value, cwd_absolute.as_ref())
{
} else {
let resolved_paths = resolve_file_system_special_paths(
value,
cwd_absolute.as_ref(),
);
if entry.access.can_write() {
writable_roots.push(path);
writable_roots.extend(resolved_paths);
} else if entry.access.can_read() {
readable_roots.push(path);
readable_roots.extend(resolved_paths);
}
}
}
FileSystemSpecialPath::Tmpdir => {
if entry.access.can_write() {
tmpdir_writable = true;
} else if entry.access.can_read()
&& let Some(path) = resolve_file_system_special_path(
slash_tmp_writable = true;
} else if entry.access.can_read() {
readable_roots.extend(resolve_file_system_special_paths(
value,
cwd_absolute.as_ref(),
)
{
readable_roots.push(path);
));
}
}
FileSystemSpecialPath::TmpdirEnvVar => {
if entry.access.can_write() {
tmpdir_writable = true;
} else if entry.access.can_read() {
readable_roots.extend(resolve_file_system_special_paths(
value,
cwd_absolute.as_ref(),
));
}
}
FileSystemSpecialPath::SlashTmp => {
if entry.access.can_write() {
slash_tmp_writable = true;
} else if entry.access.can_read()
&& let Some(path) = resolve_file_system_special_path(
} else if entry.access.can_read() {
readable_roots.extend(resolve_file_system_special_paths(
value,
cwd_absolute.as_ref(),
)
{
readable_roots.push(path);
));
}
}
FileSystemSpecialPath::Unknown { .. } => {}
@@ -728,11 +748,6 @@ impl FileSystemSandboxPolicy {
exclude_tmpdir_env_var: !tmpdir_writable,
exclude_slash_tmp: !slash_tmp_writable,
}
} else if !writable_roots.is_empty() || tmpdir_writable || slash_tmp_writable {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"permissions profile requests filesystem writes outside the workspace root, which is not supported until the runtime enforces FileSystemSandboxPolicy directly",
));
} else {
SandboxPolicy::ReadOnly {
access: read_only_access,
@@ -747,13 +762,13 @@ impl FileSystemSandboxPolicy {
let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok();
self.entries
.iter()
.filter_map(|entry| {
resolve_entry_path(&entry.path, cwd_absolute.as_ref()).map(|path| {
ResolvedFileSystemEntry {
.flat_map(|entry| {
resolve_entry_paths(&entry.path, cwd_absolute.as_ref())
.into_iter()
.map(|path| ResolvedFileSystemEntry {
path,
access: entry.access,
}
})
})
})
.collect()
}
@@ -875,7 +890,7 @@ impl From<&SandboxPolicy> for FileSystemSandboxPolicy {
if !exclude_tmpdir_env_var {
entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Tmpdir,
value: FileSystemSpecialPath::TmpdirEnvVar,
},
access: FileSystemAccessMode::Write,
});
@@ -898,21 +913,21 @@ impl From<&SandboxPolicy> for FileSystemSandboxPolicy {
fn resolve_file_system_path(
path: &FileSystemPath,
cwd: Option<&AbsolutePathBuf>,
) -> Option<AbsolutePathBuf> {
) -> Vec<AbsolutePathBuf> {
match path {
FileSystemPath::Path { path } => Some(path.clone()),
FileSystemPath::Special { value } => resolve_file_system_special_path(value, cwd),
FileSystemPath::Path { path } => vec![path.clone()],
FileSystemPath::Special { value } => resolve_file_system_special_paths(value, cwd),
}
}
fn resolve_entry_path(
fn resolve_entry_paths(
path: &FileSystemPath,
cwd: Option<&AbsolutePathBuf>,
) -> Option<AbsolutePathBuf> {
) -> Vec<AbsolutePathBuf> {
match path {
FileSystemPath::Special {
value: FileSystemSpecialPath::Root,
} => cwd.map(absolute_root_path_for_cwd),
} => cwd.map(absolute_root_path_for_cwd).into_iter().collect(),
_ => resolve_file_system_path(path, cwd),
}
}
@@ -957,6 +972,7 @@ fn special_paths_share_target(left: &FileSystemSpecialPath, right: &FileSystemSp
FileSystemSpecialPath::CurrentWorkingDirectory,
)
| (FileSystemSpecialPath::Tmpdir, FileSystemSpecialPath::Tmpdir)
| (FileSystemSpecialPath::TmpdirEnvVar, FileSystemSpecialPath::TmpdirEnvVar)
| (FileSystemSpecialPath::SlashTmp, FileSystemSpecialPath::SlashTmp) => true,
(
FileSystemSpecialPath::CurrentWorkingDirectory,
@@ -993,11 +1009,13 @@ fn special_path_matches_absolute_path(
value: &FileSystemSpecialPath,
path: &AbsolutePathBuf,
) -> bool {
match value {
FileSystemSpecialPath::Root => path.as_path().parent().is_none(),
FileSystemSpecialPath::SlashTmp => path.as_path() == Path::new("/tmp"),
_ => false,
if matches!(value, FileSystemSpecialPath::Root) {
return path.as_path().parent().is_none();
}
resolve_cwd_independent_special_paths(value)
.into_iter()
.any(|candidate| candidate == *path)
}
/// Orders resolved entries so the most specific path wins first, then applies
@@ -1017,44 +1035,106 @@ fn absolute_root_path_for_cwd(cwd: &AbsolutePathBuf) -> AbsolutePathBuf {
.unwrap_or_else(|err| panic!("cwd root must be an absolute path: {err}"))
}
fn resolve_file_system_special_path(
fn resolve_file_system_special_paths(
value: &FileSystemSpecialPath,
cwd: Option<&AbsolutePathBuf>,
) -> Option<AbsolutePathBuf> {
) -> Vec<AbsolutePathBuf> {
match value {
FileSystemSpecialPath::Root
| FileSystemSpecialPath::Minimal
| FileSystemSpecialPath::Unknown { .. } => None,
| FileSystemSpecialPath::Unknown { .. } => Vec::new(),
FileSystemSpecialPath::CurrentWorkingDirectory => {
let cwd = cwd?;
Some(cwd.clone())
let Some(cwd) = cwd else {
return Vec::new();
};
vec![cwd.clone()]
}
FileSystemSpecialPath::ProjectRoots { subpath } => {
let cwd = cwd?;
let Some(cwd) = cwd else {
return Vec::new();
};
match subpath.as_ref() {
Some(subpath) => {
AbsolutePathBuf::resolve_path_against_base(subpath, cwd.as_path()).ok()
}
None => Some(cwd.clone()),
Some(subpath) => AbsolutePathBuf::resolve_path_against_base(subpath, cwd.as_path())
.into_iter()
.collect(),
None => vec![cwd.clone()],
}
}
FileSystemSpecialPath::Tmpdir => {
let tmpdir = std::env::var_os("TMPDIR")?;
if tmpdir.is_empty() {
None
} else {
let tmpdir = AbsolutePathBuf::from_absolute_path(PathBuf::from(tmpdir)).ok()?;
Some(tmpdir)
}
FileSystemSpecialPath::Tmpdir => resolve_tmpdir_paths(),
FileSystemSpecialPath::TmpdirEnvVar => resolve_temp_env_paths(),
FileSystemSpecialPath::SlashTmp => resolve_slash_tmp_path().into_iter().collect(),
}
}
fn resolve_cwd_independent_special_paths(value: &FileSystemSpecialPath) -> Vec<AbsolutePathBuf> {
match value {
FileSystemSpecialPath::Root => Vec::new(),
FileSystemSpecialPath::Minimal
| FileSystemSpecialPath::CurrentWorkingDirectory
| FileSystemSpecialPath::ProjectRoots { .. }
| FileSystemSpecialPath::Unknown { .. } => Vec::new(),
FileSystemSpecialPath::Tmpdir => resolve_tmpdir_paths(),
FileSystemSpecialPath::TmpdirEnvVar => resolve_temp_env_paths(),
FileSystemSpecialPath::SlashTmp => resolve_slash_tmp_path().into_iter().collect(),
}
}
fn resolve_tmpdir_paths() -> Vec<AbsolutePathBuf> {
let mut paths = resolve_temp_env_paths();
if let Some(slash_tmp) = resolve_slash_tmp_path() {
paths.push(slash_tmp.clone());
if let Ok(realpath) = slash_tmp.as_path().canonicalize()
&& let Ok(realpath) = AbsolutePathBuf::from_absolute_path(realpath)
{
paths.push(realpath);
}
FileSystemSpecialPath::SlashTmp => {
#[allow(clippy::expect_used)]
let slash_tmp = AbsolutePathBuf::from_absolute_path("/tmp").expect("/tmp is absolute");
if !slash_tmp.as_path().is_dir() {
return None;
}
Some(slash_tmp)
}
dedup_absolute_paths(paths, /*normalize_effective_paths*/ false)
}
pub(crate) fn resolve_temp_env_paths() -> Vec<AbsolutePathBuf> {
dedup_absolute_paths(
temp_env_var_keys()
.iter()
.filter_map(|key| resolve_temp_env_path(key))
.collect(),
/*normalize_effective_paths*/ false,
)
}
#[cfg(windows)]
const fn temp_env_var_keys() -> &'static [&'static str] {
&["TEMP", "TMP"]
}
#[cfg(not(windows))]
const fn temp_env_var_keys() -> &'static [&'static str] {
&["TMPDIR"]
}
fn resolve_temp_env_path(key: &str) -> Option<AbsolutePathBuf> {
let value = std::env::var_os(key)?;
if value.is_empty() {
None
} else {
AbsolutePathBuf::from_absolute_path(PathBuf::from(value)).ok()
}
}
fn resolve_slash_tmp_path() -> Option<AbsolutePathBuf> {
#[cfg(not(unix))]
{
None
}
#[cfg(unix)]
{
#[allow(clippy::expect_used)]
let slash_tmp = AbsolutePathBuf::from_absolute_path("/tmp").expect("/tmp is absolute");
if !slash_tmp.as_path().is_dir() {
return None;
}
Some(slash_tmp)
}
}
@@ -1760,12 +1840,14 @@ mod tests {
#[cfg(unix)]
#[test]
fn tmpdir_special_path_canonicalizes_symlinked_tmpdir() {
fn tmpdir_env_var_special_path_canonicalizes_symlinked_tmpdir() {
if std::env::var_os(SYMLINKED_TMPDIR_TEST_ENV).is_none() {
let output = std::process::Command::new(std::env::current_exe().expect("test binary"))
.env(SYMLINKED_TMPDIR_TEST_ENV, "1")
.arg("--exact")
.arg("permissions::tests::tmpdir_special_path_canonicalizes_symlinked_tmpdir")
.arg(
"permissions::tests::tmpdir_env_var_special_path_canonicalizes_symlinked_tmpdir",
)
.output()
.expect("run tmpdir subprocess test");
@@ -1812,7 +1894,7 @@ mod tests {
let policy = FileSystemSandboxPolicy::restricted(vec![
FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Tmpdir,
value: FileSystemSpecialPath::TmpdirEnvVar,
},
access: FileSystemAccessMode::Write,
},
@@ -1842,6 +1924,143 @@ mod tests {
);
}
#[cfg(unix)]
#[test]
fn tmpdir_special_path_includes_tmpdir_env_and_tmp_namespace() {
if std::env::var_os(SYMLINKED_TMPDIR_TEST_ENV).is_none() {
let output = std::process::Command::new(std::env::current_exe().expect("test binary"))
.env(SYMLINKED_TMPDIR_TEST_ENV, "1")
.arg("--exact")
.arg(
"permissions::tests::tmpdir_special_path_includes_tmpdir_env_and_tmp_namespace",
)
.output()
.expect("run tmpdir subprocess test");
assert!(
output.status.success(),
"tmpdir subprocess test failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return;
}
let cwd = TempDir::new().expect("tempdir");
let real_tmpdir = cwd.path().join("real-tmpdir");
let link_tmpdir = cwd.path().join("link-tmpdir");
fs::create_dir_all(&real_tmpdir).expect("create real tmpdir");
symlink_dir(&real_tmpdir, &link_tmpdir).expect("create symlinked tmpdir");
unsafe {
std::env::set_var("TMPDIR", &link_tmpdir);
}
let slash_tmp =
AbsolutePathBuf::from_absolute_path("/tmp").expect("/tmp should be absolute");
let slash_tmp_realpath = AbsolutePathBuf::from_absolute_path(
slash_tmp
.as_path()
.canonicalize()
.expect("canonicalize /tmp"),
)
.expect("absolute canonical /tmp");
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Tmpdir,
},
access: FileSystemAccessMode::Write,
}]);
assert!(policy.can_write_path_with_cwd(link_tmpdir.as_path(), cwd.path()));
assert!(policy.can_write_path_with_cwd(Path::new("/tmp/codex-protocol"), cwd.path()));
assert!(
policy.can_write_path_with_cwd(
slash_tmp_realpath
.join("codex-protocol")
.expect("valid tmp child")
.as_path(),
cwd.path(),
)
);
let writable_roots = policy.get_writable_roots_with_cwd(cwd.path());
assert!(writable_roots.iter().any(|root| {
root.root
== AbsolutePathBuf::from_absolute_path(
real_tmpdir.canonicalize().expect("canonicalize tmpdir"),
)
.expect("absolute canonical tmpdir")
}));
assert!(
writable_roots
.iter()
.any(|root| root.root == slash_tmp_realpath)
);
}
#[test]
fn tmpdir_write_without_workspace_root_falls_back_to_read_only_legacy_policy()
-> std::io::Result<()> {
let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Tmpdir,
},
access: FileSystemAccessMode::Write,
}]);
let sandbox_policy = policy.to_legacy_sandbox_policy(
NetworkSandboxPolicy::Restricted,
Path::new("/tmp/workspace"),
)?;
assert_eq!(
sandbox_policy,
SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::Restricted {
include_platform_defaults: false,
readable_roots: Vec::new(),
},
network_access: false,
}
);
assert!(policy.needs_direct_runtime_enforcement(
NetworkSandboxPolicy::Restricted,
Path::new("/tmp/workspace"),
));
Ok(())
}
#[test]
fn legacy_workspace_write_uses_tmpdir_env_var_special_path() {
let file_system_policy = FileSystemSandboxPolicy::from(&SandboxPolicy::WorkspaceWrite {
writable_roots: Vec::new(),
read_only_access: ReadOnlyAccess::Restricted {
include_platform_defaults: false,
readable_roots: Vec::new(),
},
network_access: false,
exclude_tmpdir_env_var: false,
exclude_slash_tmp: true,
});
assert!(file_system_policy.entries.iter().any(|entry| {
entry.access == FileSystemAccessMode::Write
&& entry.path
== FileSystemPath::Special {
value: FileSystemSpecialPath::TmpdirEnvVar,
}
}));
assert!(!file_system_policy.entries.iter().any(|entry| {
entry.access == FileSystemAccessMode::Write
&& entry.path
== FileSystemPath::Special {
value: FileSystemSpecialPath::Tmpdir,
}
}));
}
#[test]
fn resolve_access_with_cwd_uses_most_specific_entry() {
let cwd = TempDir::new().expect("tempdir");

View File

@@ -79,6 +79,7 @@ pub use crate::permissions::FileSystemSandboxKind;
pub use crate::permissions::FileSystemSandboxPolicy;
pub use crate::permissions::FileSystemSpecialPath;
pub use crate::permissions::NetworkSandboxPolicy;
use crate::permissions::resolve_temp_env_paths;
pub use crate::request_permissions::RequestPermissionsArgs;
pub use crate::request_user_input::RequestUserInputEvent;
@@ -1047,28 +1048,12 @@ impl SandboxPolicy {
}
}
// Include $TMPDIR unless explicitly excluded. On macOS, TMPDIR
// is per-user, so writes to TMPDIR should not be readable by
// other users on the system.
//
// By comparison, TMPDIR is not guaranteed to be defined on
// Linux or Windows, but supporting it here gives users a way to
// provide the model with their own temporary directory without
// having to hardcode it in the config.
if !exclude_tmpdir_env_var
&& let Some(tmpdir) = std::env::var_os("TMPDIR")
&& !tmpdir.is_empty()
{
match AbsolutePathBuf::from_absolute_path(PathBuf::from(&tmpdir)) {
Ok(tmpdir_path) => {
roots.push(tmpdir_path);
}
Err(e) => {
error!(
"Ignoring invalid TMPDIR value {tmpdir:?} for sandbox writable root: {e}",
);
}
}
// Include platform temp env roots unless explicitly excluded.
// On Unix, this keeps the legacy TMPDIR behavior. On Windows,
// this picks up TEMP/TMP so legacy workspace-write policies
// keep matching the host temp directory.
if !exclude_tmpdir_env_var {
roots.extend(resolve_temp_env_paths());
}
// For each root, compute subpaths that should remain read-only.
@@ -4181,7 +4166,7 @@ mod tests {
}
#[test]
fn file_system_policy_rejects_legacy_bridge_for_non_workspace_writes() {
fn file_system_policy_falls_back_to_read_only_legacy_bridge_for_non_workspace_writes() {
let cwd = if cfg!(windows) {
Path::new(r"C:\workspace")
} else {
@@ -4199,14 +4184,19 @@ mod tests {
access: FileSystemAccessMode::Write,
}]);
let err = policy
let legacy_policy = policy
.to_legacy_sandbox_policy(NetworkSandboxPolicy::Restricted, cwd)
.expect_err("non-workspace writes should be rejected");
.expect("non-workspace writes should fall back to read-only");
assert!(
err.to_string()
.contains("filesystem writes outside the workspace root"),
"{err}"
assert_eq!(
legacy_policy,
SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::Restricted {
include_platform_defaults: false,
readable_roots: Vec::new(),
},
network_access: false,
}
);
}

View File

@@ -90,12 +90,6 @@
(allow file-read* file-test-existence file-write-data file-ioctl
(literal "/dev/dtracehelper"))
; Scratch space so tools can create temp files.
(allow file-read* file-test-existence file-write* (subpath "/tmp"))
(allow file-read* file-write* (subpath "/private/tmp"))
(allow file-read* file-write* (subpath "/var/tmp"))
(allow file-read* file-write* (subpath "/private/var/tmp"))
; Allow reading standard config directories.
(allow file-read* (subpath "/etc"))
(allow file-read* (subpath "/private/etc"))

View File

@@ -60,6 +60,31 @@ fn base_policy_allows_node_cpu_sysctls() {
);
}
#[test]
fn restricted_platform_defaults_do_not_include_tmp_write_rules() {
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
path: FileSystemPath::Special {
value: FileSystemSpecialPath::Minimal,
},
access: FileSystemAccessMode::Read,
}]);
let args = create_seatbelt_command_args_for_policies(
vec!["/bin/true".to_string()],
&file_system_policy,
NetworkSandboxPolicy::Restricted,
Path::new("/"),
false,
None,
);
let policy = seatbelt_policy_arg(&args);
assert!(!policy.contains("(subpath \"/tmp\")"));
assert!(!policy.contains("(subpath \"/private/tmp\")"));
assert!(!policy.contains("(subpath \"/var/tmp\")"));
assert!(!policy.contains("(subpath \"/private/var/tmp\")"));
}
#[test]
fn create_seatbelt_args_routes_network_through_proxy_ports() {
let policy = dynamic_network_policy(

View File

@@ -44,33 +44,43 @@ export function createTestClient(options: CreateTestClientOptions = {}): TestCli
codexPathOverride: codexExecPath,
baseUrl: options.baseUrl,
apiKey: options.apiKey,
config: mergeTestProviderConfig(options.baseUrl, options.config),
config: mergeTestConfig(options.baseUrl, options.config),
env,
}),
};
}
function mergeTestProviderConfig(
function mergeTestConfig(
baseUrl: string | undefined,
config: CodexConfigObject | undefined,
): CodexConfigObject | undefined {
if (!baseUrl || hasExplicitProviderConfig(config)) {
return config;
}
const mergedConfig: CodexConfigObject | undefined =
!baseUrl || hasExplicitProviderConfig(config)
? config
: {
...config,
// Built-in providers are merged before user config, so tests need a
// custom provider entry to force SSE against the local mock server.
model_provider: "mock",
model_providers: {
mock: {
name: "Mock provider for test",
base_url: baseUrl,
wire_api: "responses",
supports_websockets: false,
},
},
};
const featureOverrides = mergedConfig?.features;
// Built-in providers are merged before user config, so tests need a custom
// provider entry to force SSE against the local mock server.
return {
...config,
model_provider: "mock",
model_providers: {
mock: {
name: "Mock provider for test",
base_url: baseUrl,
wire_api: "responses",
supports_websockets: false,
},
},
...mergedConfig,
// Disable plugins in SDK integration tests so background curated-plugin
// sync does not race temp CODEX_HOME cleanup.
features:
featureOverrides && typeof featureOverrides === "object" && !Array.isArray(featureOverrides)
? { ...featureOverrides, plugins: false }
: { plugins: false },
};
}