mirror of
https://github.com/openai/codex.git
synced 2026-03-18 20:53:55 +00:00
Compare commits
2 Commits
dev/cc/mul
...
pr14989
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
753a0bdf10 | ||
|
|
392347d436 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Use a rust-release version that includes all native binaries.
|
||||
CODEX_VERSION=0.74.0
|
||||
CODEX_VERSION=0.115.0
|
||||
OUTPUT_DIR="${RUNNER_TEMP}"
|
||||
python3 ./scripts/stage_npm_packages.py \
|
||||
--release-version "$CODEX_VERSION" \
|
||||
|
||||
14
codex-rs/Cargo.lock
generated
14
codex-rs/Cargo.lock
generated
@@ -1557,6 +1557,7 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"codex-apply-patch",
|
||||
"codex-fs-ops",
|
||||
"codex-linux-sandbox",
|
||||
"codex-shell-escalation",
|
||||
"codex-utils-home-dir",
|
||||
@@ -1846,6 +1847,7 @@ dependencies = [
|
||||
"codex-environment",
|
||||
"codex-execpolicy",
|
||||
"codex-file-search",
|
||||
"codex-fs-ops",
|
||||
"codex-git",
|
||||
"codex-hooks",
|
||||
"codex-keyring-store",
|
||||
@@ -2077,6 +2079,18 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-fs-ops"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-git"
|
||||
version = "0.0.0"
|
||||
|
||||
@@ -11,6 +11,7 @@ members = [
|
||||
"apply-patch",
|
||||
"arg0",
|
||||
"feedback",
|
||||
"fs-ops",
|
||||
"codex-backend-openapi-models",
|
||||
"cloud-requirements",
|
||||
"cloud-tasks",
|
||||
@@ -109,6 +110,7 @@ codex-exec = { path = "exec" }
|
||||
codex-execpolicy = { path = "execpolicy" }
|
||||
codex-experimental-api-macros = { path = "codex-experimental-api-macros" }
|
||||
codex-feedback = { path = "feedback" }
|
||||
codex-fs-ops = { path = "fs-ops" }
|
||||
codex-file-search = { path = "file-search" }
|
||||
codex-git = { path = "utils/git" }
|
||||
codex-hooks = { path = "hooks" }
|
||||
|
||||
@@ -14,6 +14,7 @@ workspace = true
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
codex-apply-patch = { workspace = true }
|
||||
codex-fs-ops = { workspace = true }
|
||||
codex-linux-sandbox = { workspace = true }
|
||||
codex-shell-escalation = { workspace = true }
|
||||
codex-utils-home-dir = { workspace = true }
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1;
|
||||
use codex_fs_ops::CODEX_CORE_FS_OPS_ARG1;
|
||||
use codex_utils_home_dir::find_codex_home;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::symlink;
|
||||
@@ -105,6 +106,16 @@ pub fn arg0_dispatch() -> Option<Arg0PathEntryGuard> {
|
||||
};
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
if argv1 == CODEX_CORE_FS_OPS_ARG1 {
|
||||
let exit_code = match codex_fs_ops::run_from_args(args) {
|
||||
Ok(()) => 0,
|
||||
Err(err) => {
|
||||
eprintln!("Error: failed to run fs helper: {err}");
|
||||
1
|
||||
}
|
||||
};
|
||||
std::process::exit(exit_code);
|
||||
}
|
||||
|
||||
// This modifies the environment, which is not thread-safe, so do this
|
||||
// before creating any threads/the Tokio runtime.
|
||||
|
||||
@@ -38,6 +38,7 @@ codex-environment = { workspace = true }
|
||||
codex-shell-command = { workspace = true }
|
||||
codex-skills = { workspace = true }
|
||||
codex-execpolicy = { workspace = true }
|
||||
codex-fs-ops = { workspace = true }
|
||||
codex-file-search = { workspace = true }
|
||||
codex-git = { workspace = true }
|
||||
codex-hooks = { workspace = true }
|
||||
|
||||
@@ -42,7 +42,6 @@ use crate::realtime_conversation::handle_close as handle_realtime_conversation_c
|
||||
use crate::realtime_conversation::handle_start as handle_realtime_conversation_start;
|
||||
use crate::realtime_conversation::handle_text as handle_realtime_conversation_text;
|
||||
use crate::rollout::session_index;
|
||||
use crate::skill_network_proxy_cache::SkillNetworkProxyCache;
|
||||
use crate::skills::render_skills_section;
|
||||
use crate::stream_events_utils::HandleOutputCtx;
|
||||
use crate::stream_events_utils::handle_non_tool_response_item;
|
||||
@@ -370,7 +369,6 @@ pub(crate) struct CodexSpawnArgs {
|
||||
pub(crate) plugins_manager: Arc<PluginsManager>,
|
||||
pub(crate) mcp_manager: Arc<McpManager>,
|
||||
pub(crate) file_watcher: Arc<FileWatcher>,
|
||||
pub(crate) skill_network_proxy_cache: Arc<SkillNetworkProxyCache>,
|
||||
pub(crate) conversation_history: InitialHistory,
|
||||
pub(crate) session_source: SessionSource,
|
||||
pub(crate) agent_control: AgentControl,
|
||||
@@ -424,7 +422,6 @@ impl Codex {
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
file_watcher,
|
||||
skill_network_proxy_cache,
|
||||
conversation_history,
|
||||
session_source,
|
||||
agent_control,
|
||||
@@ -613,7 +610,6 @@ impl Codex {
|
||||
plugins_manager,
|
||||
mcp_manager.clone(),
|
||||
file_watcher,
|
||||
skill_network_proxy_cache,
|
||||
agent_control,
|
||||
)
|
||||
.await
|
||||
@@ -1219,58 +1215,6 @@ impl Session {
|
||||
Ok((network_proxy, session_network_proxy))
|
||||
}
|
||||
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
fn shared_skill_network_proxy_spec(
|
||||
&self,
|
||||
skill: &SkillMetadata,
|
||||
) -> Option<crate::config::NetworkProxySpec> {
|
||||
let managed_network_override = skill.managed_network_override.as_ref()?;
|
||||
let base_spec = self.services.network_proxy_spec.as_ref()?;
|
||||
Some(base_spec.with_skill_managed_network_override(managed_network_override))
|
||||
}
|
||||
|
||||
/// Returns a shared managed network proxy for a skill-specific network override.
|
||||
///
|
||||
/// This is currently only used by the Unix zsh-fork executable-level escalation
|
||||
/// path. Non-Unix platforms fall back to the normal shell/unified-exec flows and
|
||||
/// do not currently swap in per-skill managed proxies, so this helper is dead
|
||||
/// code there by design.
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pub(crate) async fn get_or_start_skill_network_proxy(
|
||||
self: &Arc<Self>,
|
||||
skill: &SkillMetadata,
|
||||
) -> anyhow::Result<Option<NetworkProxy>> {
|
||||
let Some(spec) = self.shared_skill_network_proxy_spec(skill) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let key = spec.shared_skill_proxy_key();
|
||||
let sandbox_policy = {
|
||||
let state = self.state.lock().await;
|
||||
state.session_configuration.sandbox_policy.get().clone()
|
||||
};
|
||||
let proxy = self
|
||||
.services
|
||||
.skill_network_proxy_cache
|
||||
.get_or_start(key, || async move {
|
||||
// Shared skill proxies are cached by spec and reused across commands, so this
|
||||
// startup path must not capture turn-specific approval callbacks or per-command
|
||||
// audit context. The proxy should enforce the precomputed skill override in
|
||||
// `spec` only, which is why the optional hooks stay `None`, approval flow is
|
||||
// disabled, and audit metadata falls back to the empty default.
|
||||
spec.start_proxy(
|
||||
&sandbox_policy,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
NetworkProxyAuditMetadata::default(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("failed to start shared skill network proxy: {err}"))
|
||||
})
|
||||
.await?;
|
||||
Ok(Some(proxy.proxy()))
|
||||
}
|
||||
|
||||
/// Don't expand the number of mutated arguments on config. We are in the process of getting rid of it.
|
||||
pub(crate) fn build_per_turn_config(session_configuration: &SessionConfiguration) -> Config {
|
||||
// todo(aibrahim): store this state somewhere else so we don't need to mut config
|
||||
@@ -1457,7 +1401,6 @@ impl Session {
|
||||
plugins_manager: Arc<PluginsManager>,
|
||||
mcp_manager: Arc<McpManager>,
|
||||
file_watcher: Arc<FileWatcher>,
|
||||
skill_network_proxy_cache: Arc<SkillNetworkProxyCache>,
|
||||
agent_control: AgentControl,
|
||||
) -> anyhow::Result<Arc<Self>> {
|
||||
debug!(
|
||||
@@ -1866,9 +1809,7 @@ impl Session {
|
||||
mcp_manager: Arc::clone(&mcp_manager),
|
||||
file_watcher,
|
||||
agent_control,
|
||||
network_proxy_spec: config.permissions.network.clone().map(Arc::new),
|
||||
network_proxy,
|
||||
skill_network_proxy_cache,
|
||||
network_approval: Arc::clone(&network_approval),
|
||||
state_db: state_db_ctx.clone(),
|
||||
model_client: ModelClient::new(
|
||||
|
||||
@@ -81,7 +81,6 @@ pub(crate) async fn run_codex_thread_interactive(
|
||||
plugins_manager: Arc::clone(&parent_session.services.plugins_manager),
|
||||
mcp_manager: Arc::clone(&parent_session.services.mcp_manager),
|
||||
file_watcher: Arc::clone(&parent_session.services.file_watcher),
|
||||
skill_network_proxy_cache: Arc::clone(&parent_session.services.skill_network_proxy_cache),
|
||||
conversation_history: initial_history.unwrap_or(InitialHistory::New),
|
||||
session_source: SessionSource::SubAgent(subagent_source),
|
||||
agent_control: parent_session.services.agent_control.clone(),
|
||||
|
||||
@@ -2373,7 +2373,6 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
Arc::new(FileWatcher::noop()),
|
||||
Arc::new(crate::skill_network_proxy_cache::SkillNetworkProxyCache::new()),
|
||||
AgentControl::default(),
|
||||
)
|
||||
.await;
|
||||
@@ -2504,11 +2503,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
mcp_manager,
|
||||
file_watcher,
|
||||
agent_control,
|
||||
network_proxy_spec: None,
|
||||
network_proxy: None,
|
||||
skill_network_proxy_cache: Arc::new(
|
||||
crate::skill_network_proxy_cache::SkillNetworkProxyCache::new(),
|
||||
),
|
||||
network_approval: Arc::clone(&network_approval),
|
||||
state_db: None,
|
||||
model_client: ModelClient::new(
|
||||
@@ -3302,11 +3297,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
mcp_manager,
|
||||
file_watcher,
|
||||
agent_control,
|
||||
network_proxy_spec: None,
|
||||
network_proxy: None,
|
||||
skill_network_proxy_cache: Arc::new(
|
||||
crate::skill_network_proxy_cache::SkillNetworkProxyCache::new(),
|
||||
),
|
||||
network_approval: Arc::clone(&network_approval),
|
||||
state_db: None,
|
||||
model_client: ModelClient::new(
|
||||
|
||||
@@ -443,9 +443,6 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
file_watcher,
|
||||
skill_network_proxy_cache: Arc::new(
|
||||
crate::skill_network_proxy_cache::SkillNetworkProxyCache::new(),
|
||||
),
|
||||
conversation_history: InitialHistory::New,
|
||||
session_source: SessionSource::SubAgent(SubAgentSource::Other(
|
||||
GUARDIAN_REVIEWER_NAME.to_string(),
|
||||
|
||||
@@ -120,7 +120,6 @@ pub use codex_network_proxy::NetworkProxyAuditMetadata;
|
||||
|
||||
pub use managed_features::ManagedFeatures;
|
||||
pub use network_proxy_spec::NetworkProxySpec;
|
||||
pub(crate) use network_proxy_spec::SkillNetworkProxyKey;
|
||||
pub use network_proxy_spec::StartedNetworkProxy;
|
||||
pub use permissions::FilesystemPermissionToml;
|
||||
pub use permissions::FilesystemPermissionsToml;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use crate::config_loader::NetworkConstraints;
|
||||
use crate::skills::model::SkillManagedNetworkOverride;
|
||||
use async_trait::async_trait;
|
||||
use codex_execpolicy::Policy;
|
||||
use codex_network_proxy::BlockedRequestObserver;
|
||||
@@ -21,9 +20,6 @@ use codex_protocol::protocol::SandboxPolicy;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct SkillNetworkProxyKey(String);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NetworkProxySpec {
|
||||
config: NetworkProxyConfig,
|
||||
@@ -88,35 +84,6 @@ impl NetworkProxySpec {
|
||||
self.config.network.enable_socks5
|
||||
}
|
||||
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pub(crate) fn with_skill_managed_network_override(
|
||||
&self,
|
||||
managed_network_override: &SkillManagedNetworkOverride,
|
||||
) -> Self {
|
||||
let mut spec = self.clone();
|
||||
if let Some(allowed_domains) = managed_network_override.allowed_domains.as_ref() {
|
||||
spec.config.network.allowed_domains = allowed_domains.clone();
|
||||
spec.constraints.allowed_domains = Some(allowed_domains.clone());
|
||||
}
|
||||
if let Some(denied_domains) = managed_network_override.denied_domains.as_ref() {
|
||||
spec.config.network.denied_domains = denied_domains.clone();
|
||||
spec.constraints.denied_domains = Some(denied_domains.clone());
|
||||
}
|
||||
spec
|
||||
}
|
||||
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pub(crate) fn shared_skill_proxy_key(&self) -> SkillNetworkProxyKey {
|
||||
let mut normalized = self.clone();
|
||||
sort_string_list(&mut normalized.config.network.allowed_domains);
|
||||
sort_string_list(&mut normalized.config.network.denied_domains);
|
||||
sort_string_list(&mut normalized.config.network.allow_unix_sockets);
|
||||
sort_option_string_list(&mut normalized.constraints.allowed_domains);
|
||||
sort_option_string_list(&mut normalized.constraints.denied_domains);
|
||||
sort_option_string_list(&mut normalized.constraints.allow_unix_sockets);
|
||||
SkillNetworkProxyKey(format!("{normalized:?}"))
|
||||
}
|
||||
|
||||
pub(crate) fn from_config_and_constraints(
|
||||
config: NetworkProxyConfig,
|
||||
requirements: Option<NetworkConstraints>,
|
||||
@@ -365,18 +332,6 @@ fn upsert_network_domains(
|
||||
target.extend(deduped_hosts);
|
||||
}
|
||||
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
fn sort_string_list(values: &mut [String]) {
|
||||
values.sort_unstable();
|
||||
}
|
||||
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
fn sort_option_string_list(values: &mut Option<Vec<String>>) {
|
||||
if let Some(values) = values.as_mut() {
|
||||
sort_string_list(values);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "network_proxy_spec_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use super::*;
|
||||
use crate::skills::model::SkillManagedNetworkOverride;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
@@ -201,99 +200,3 @@ fn requirements_denied_domains_are_a_baseline_for_default_mode() {
|
||||
);
|
||||
assert_eq!(spec.constraints.denylist_expansion_enabled, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_managed_network_override_replaces_only_requested_domain_lists() {
|
||||
let mut config = NetworkProxyConfig::default();
|
||||
config.network.allowed_domains = vec!["base-allow.example.com".to_string()];
|
||||
config.network.denied_domains = vec!["base-deny.example.com".to_string()];
|
||||
let spec = NetworkProxySpec {
|
||||
config,
|
||||
constraints: NetworkProxyConstraints {
|
||||
allowed_domains: Some(vec!["base-allow.example.com".to_string()]),
|
||||
denied_domains: Some(vec!["base-deny.example.com".to_string()]),
|
||||
allowlist_expansion_enabled: Some(false),
|
||||
denylist_expansion_enabled: Some(false),
|
||||
..NetworkProxyConstraints::default()
|
||||
},
|
||||
hard_deny_allowlist_misses: true,
|
||||
};
|
||||
|
||||
let updated = spec.with_skill_managed_network_override(&SkillManagedNetworkOverride {
|
||||
allowed_domains: Some(vec!["skill-allow.example.com".to_string()]),
|
||||
denied_domains: None,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
updated.config.network.allowed_domains,
|
||||
vec!["skill-allow.example.com".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
updated.config.network.denied_domains,
|
||||
vec!["base-deny.example.com".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
updated.constraints.allowed_domains,
|
||||
Some(vec!["skill-allow.example.com".to_string()])
|
||||
);
|
||||
assert_eq!(
|
||||
updated.constraints.denied_domains,
|
||||
Some(vec!["base-deny.example.com".to_string()])
|
||||
);
|
||||
assert_eq!(updated.constraints.allowlist_expansion_enabled, Some(false));
|
||||
assert_eq!(updated.constraints.denylist_expansion_enabled, Some(false));
|
||||
assert!(updated.hard_deny_allowlist_misses);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_skill_proxy_key_normalizes_domain_order() {
|
||||
let mut base_config = NetworkProxyConfig::default();
|
||||
base_config.network.allowed_domains =
|
||||
vec!["b.example.com".to_string(), "a.example.com".to_string()];
|
||||
base_config.network.denied_domains = vec![
|
||||
"deny-b.example.com".to_string(),
|
||||
"deny-a.example.com".to_string(),
|
||||
];
|
||||
let base = NetworkProxySpec {
|
||||
config: base_config,
|
||||
constraints: NetworkProxyConstraints {
|
||||
allowed_domains: Some(vec![
|
||||
"managed-b.example.com".to_string(),
|
||||
"managed-a.example.com".to_string(),
|
||||
]),
|
||||
denied_domains: Some(vec![
|
||||
"managed-deny-b.example.com".to_string(),
|
||||
"managed-deny-a.example.com".to_string(),
|
||||
]),
|
||||
..NetworkProxyConstraints::default()
|
||||
},
|
||||
hard_deny_allowlist_misses: false,
|
||||
};
|
||||
let mut reordered_config = NetworkProxyConfig::default();
|
||||
reordered_config.network.allowed_domains =
|
||||
vec!["a.example.com".to_string(), "b.example.com".to_string()];
|
||||
reordered_config.network.denied_domains = vec![
|
||||
"deny-a.example.com".to_string(),
|
||||
"deny-b.example.com".to_string(),
|
||||
];
|
||||
let reordered = NetworkProxySpec {
|
||||
config: reordered_config,
|
||||
constraints: NetworkProxyConstraints {
|
||||
allowed_domains: Some(vec![
|
||||
"managed-a.example.com".to_string(),
|
||||
"managed-b.example.com".to_string(),
|
||||
]),
|
||||
denied_domains: Some(vec![
|
||||
"managed-deny-a.example.com".to_string(),
|
||||
"managed-deny-b.example.com".to_string(),
|
||||
]),
|
||||
..NetworkProxyConstraints::default()
|
||||
},
|
||||
hard_deny_allowlist_misses: false,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
base.shared_skill_proxy_key(),
|
||||
reordered.shared_skill_proxy_key()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,10 +114,10 @@ pub mod default_client;
|
||||
pub mod project_doc;
|
||||
mod rollout;
|
||||
pub(crate) mod safety;
|
||||
mod sandboxed_fs;
|
||||
pub mod seatbelt;
|
||||
pub mod shell;
|
||||
pub mod shell_snapshot;
|
||||
mod skill_network_proxy_cache;
|
||||
pub mod skills;
|
||||
pub mod spawn;
|
||||
pub mod state_db;
|
||||
|
||||
256
codex-rs/core/src/sandboxed_fs.rs
Normal file
256
codex-rs/core/src/sandboxed_fs.rs
Normal file
@@ -0,0 +1,256 @@
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::exec::ExecExpiration;
|
||||
use crate::sandboxing::CommandSpec;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::sandboxing::execute_env;
|
||||
use crate::sandboxing::merge_permission_profiles;
|
||||
use crate::tools::sandboxing::SandboxAttempt;
|
||||
use crate::tools::sandboxing::SandboxablePreference;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_fs_ops::CODEX_CORE_FS_OPS_ARG1;
|
||||
use codex_fs_ops::FsError;
|
||||
use codex_fs_ops::FsErrorKind;
|
||||
use codex_fs_ops::FsPayload;
|
||||
use codex_fs_ops::FsResponse;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
const SANDBOXED_FS_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum SandboxedFsError {
|
||||
#[error("failed to determine codex executable: {message}")]
|
||||
ResolveExe { message: String },
|
||||
#[error("sandboxed fs helper timed out while accessing `{path}`")]
|
||||
TimedOut { path: PathBuf },
|
||||
#[error("sandboxed fs helper exited with code {exit_code} while accessing `{path}`: {message}")]
|
||||
ProcessFailed {
|
||||
path: PathBuf,
|
||||
exit_code: i32,
|
||||
message: String,
|
||||
},
|
||||
#[error("sandboxed fs helper returned invalid output for `{path}`: {message}")]
|
||||
InvalidResponse { path: PathBuf, message: String },
|
||||
#[error("sandboxed fs helper could not access `{path}`: {error}")]
|
||||
Operation { path: PathBuf, error: FsError },
|
||||
}
|
||||
|
||||
impl SandboxedFsError {
|
||||
pub(crate) fn operation_error_kind(&self) -> Option<&FsErrorKind> {
|
||||
match self {
|
||||
Self::Operation { error, .. } => Some(&error.kind),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn operation_error_message(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Operation { error, .. } => Some(error.message.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn to_io_error(&self) -> std::io::Error {
|
||||
match self {
|
||||
Self::Operation { error, .. } => error.to_io_error(),
|
||||
Self::TimedOut { .. } => {
|
||||
std::io::Error::new(std::io::ErrorKind::TimedOut, self.to_string())
|
||||
}
|
||||
Self::ResolveExe { .. } | Self::ProcessFailed { .. } | Self::InvalidResponse { .. } => {
|
||||
std::io::Error::other(self.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn read_bytes(
|
||||
session: &Arc<Session>,
|
||||
turn: &Arc<TurnContext>,
|
||||
path: &Path,
|
||||
) -> Result<Vec<u8>, SandboxedFsError> {
|
||||
let path_buf = path.to_path_buf();
|
||||
let payload = run_request(session, turn, "read_bytes", &path_buf).await?;
|
||||
let FsPayload::Bytes { base64 } = payload else {
|
||||
return Err(SandboxedFsError::InvalidResponse {
|
||||
path: path_buf,
|
||||
message: "expected bytes payload".to_string(),
|
||||
});
|
||||
};
|
||||
BASE64_STANDARD
|
||||
.decode(base64)
|
||||
.map_err(|error| SandboxedFsError::InvalidResponse {
|
||||
path: path.to_path_buf(),
|
||||
message: format!("failed to decode base64 payload: {error}"),
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn read_text(
|
||||
session: &Arc<Session>,
|
||||
turn: &Arc<TurnContext>,
|
||||
path: &Path,
|
||||
) -> Result<String, SandboxedFsError> {
|
||||
let path_buf = path.to_path_buf();
|
||||
let payload = run_request(session, turn, "read_text", &path_buf).await?;
|
||||
let FsPayload::Text { text } = payload else {
|
||||
return Err(SandboxedFsError::InvalidResponse {
|
||||
path: path_buf,
|
||||
message: "expected text payload".to_string(),
|
||||
});
|
||||
};
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
async fn run_request(
|
||||
session: &Arc<Session>,
|
||||
turn: &Arc<TurnContext>,
|
||||
operation: &str,
|
||||
path: &Path,
|
||||
) -> Result<FsPayload, SandboxedFsError> {
|
||||
let exe = resolve_codex_exe(turn)?;
|
||||
let additional_permissions = effective_granted_permissions(session).await;
|
||||
let sandbox_manager = crate::sandboxing::SandboxManager::new();
|
||||
let attempt = SandboxAttempt {
|
||||
sandbox: sandbox_manager.select_initial(
|
||||
&turn.file_system_sandbox_policy,
|
||||
turn.network_sandbox_policy,
|
||||
SandboxablePreference::Auto,
|
||||
turn.windows_sandbox_level,
|
||||
/*has_managed_network_requirements*/ false,
|
||||
),
|
||||
policy: &turn.sandbox_policy,
|
||||
file_system_policy: &turn.file_system_sandbox_policy,
|
||||
network_policy: turn.network_sandbox_policy,
|
||||
enforce_managed_network: false,
|
||||
manager: &sandbox_manager,
|
||||
sandbox_cwd: &turn.cwd,
|
||||
codex_linux_sandbox_exe: turn.codex_linux_sandbox_exe.as_ref(),
|
||||
use_legacy_landlock: turn.features.use_legacy_landlock(),
|
||||
windows_sandbox_level: turn.windows_sandbox_level,
|
||||
windows_sandbox_private_desktop: turn.config.permissions.windows_sandbox_private_desktop,
|
||||
};
|
||||
let exec_request = attempt
|
||||
.env_for(
|
||||
CommandSpec {
|
||||
program: exe.to_string_lossy().to_string(),
|
||||
args: vec![
|
||||
CODEX_CORE_FS_OPS_ARG1.to_string(),
|
||||
operation.to_string(),
|
||||
path.to_string_lossy().to_string(),
|
||||
],
|
||||
cwd: turn.cwd.clone(),
|
||||
env: HashMap::new(),
|
||||
expiration: ExecExpiration::Timeout(SANDBOXED_FS_TIMEOUT),
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
additional_permissions,
|
||||
justification: None,
|
||||
},
|
||||
/*network*/ None,
|
||||
)
|
||||
.map_err(|error| SandboxedFsError::ProcessFailed {
|
||||
path: path.to_path_buf(),
|
||||
exit_code: -1,
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
let output = execute_env(exec_request, /*stdout_stream*/ None)
|
||||
.await
|
||||
.map_err(|error| SandboxedFsError::ProcessFailed {
|
||||
path: path.to_path_buf(),
|
||||
exit_code: -1,
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
|
||||
if output.timed_out {
|
||||
return Err(SandboxedFsError::TimedOut {
|
||||
path: path.to_path_buf(),
|
||||
});
|
||||
}
|
||||
if output.exit_code != 0 {
|
||||
let stderr = output.stderr.text.trim();
|
||||
let stdout = output.stdout.text.trim();
|
||||
let message = if !stderr.is_empty() {
|
||||
stderr.to_string()
|
||||
} else if !stdout.is_empty() {
|
||||
stdout.to_string()
|
||||
} else {
|
||||
"no error details emitted".to_string()
|
||||
};
|
||||
return Err(SandboxedFsError::ProcessFailed {
|
||||
path: path.to_path_buf(),
|
||||
exit_code: output.exit_code,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
let response: FsResponse =
|
||||
serde_json::from_str(output.stdout.text.trim()).map_err(|error| {
|
||||
SandboxedFsError::InvalidResponse {
|
||||
path: path.to_path_buf(),
|
||||
message: format!("failed to parse helper response: {error}"),
|
||||
}
|
||||
})?;
|
||||
|
||||
match response {
|
||||
FsResponse::Success { payload } => Ok(payload),
|
||||
FsResponse::Error { error } => Err(SandboxedFsError::Operation {
|
||||
path: path.to_path_buf(),
|
||||
error,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn effective_granted_permissions(session: &Session) -> Option<PermissionProfile> {
|
||||
let granted_session_permissions = session.granted_session_permissions().await;
|
||||
let granted_turn_permissions = session.granted_turn_permissions().await;
|
||||
merge_permission_profiles(
|
||||
granted_session_permissions.as_ref(),
|
||||
granted_turn_permissions.as_ref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_codex_exe(turn: &TurnContext) -> Result<PathBuf, SandboxedFsError> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let current_exe =
|
||||
std::env::current_exe().map_err(|error| SandboxedFsError::ResolveExe {
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
if is_codex_launcher(¤t_exe) {
|
||||
return Ok(current_exe);
|
||||
}
|
||||
Ok(codex_windows_sandbox::resolve_current_exe_for_launch(
|
||||
&turn.config.codex_home,
|
||||
"codex.exe",
|
||||
))
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = turn;
|
||||
let current_exe =
|
||||
std::env::current_exe().map_err(|error| SandboxedFsError::ResolveExe {
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
if is_codex_launcher(¤t_exe) {
|
||||
return Ok(current_exe);
|
||||
}
|
||||
Ok(current_exe)
|
||||
}
|
||||
}
|
||||
|
||||
fn is_codex_launcher(path: &Path) -> bool {
|
||||
path.file_name()
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.is_some_and(|name| {
|
||||
matches!(
|
||||
name,
|
||||
"codex" | "codex.exe" | "codex-exec" | "codex-exec.exe"
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
use crate::config::SkillNetworkProxyKey;
|
||||
use crate::config::StartedNetworkProxy;
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct SkillNetworkProxyCache {
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
proxies: Mutex<HashMap<SkillNetworkProxyKey, Arc<StartedNetworkProxy>>>,
|
||||
}
|
||||
|
||||
impl SkillNetworkProxyCache {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pub(crate) async fn get_or_start<F, Fut>(
|
||||
&self,
|
||||
key: SkillNetworkProxyKey,
|
||||
start: F,
|
||||
) -> anyhow::Result<Arc<StartedNetworkProxy>>
|
||||
where
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = anyhow::Result<StartedNetworkProxy>>,
|
||||
{
|
||||
let mut proxies = self.proxies.lock().await;
|
||||
if let Some(proxy) = proxies.get(&key) {
|
||||
return Ok(Arc::clone(proxy));
|
||||
}
|
||||
|
||||
let proxy = Arc::new(start().await?);
|
||||
proxies.insert(key, Arc::clone(&proxy));
|
||||
Ok(proxy)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ use crate::RolloutRecorder;
|
||||
use crate::agent::AgentControl;
|
||||
use crate::analytics_client::AnalyticsEventsClient;
|
||||
use crate::client::ModelClient;
|
||||
use crate::config::NetworkProxySpec;
|
||||
use crate::config::StartedNetworkProxy;
|
||||
use crate::exec_policy::ExecPolicyManager;
|
||||
use crate::file_watcher::FileWatcher;
|
||||
@@ -14,7 +13,6 @@ use crate::mcp::McpManager;
|
||||
use crate::mcp_connection_manager::McpConnectionManager;
|
||||
use crate::models_manager::manager::ModelsManager;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::skill_network_proxy_cache::SkillNetworkProxyCache;
|
||||
use crate::skills::SkillsManager;
|
||||
use crate::state_db::StateDbHandle;
|
||||
use crate::tools::code_mode::CodeModeService;
|
||||
@@ -58,10 +56,7 @@ pub(crate) struct SessionServices {
|
||||
pub(crate) mcp_manager: Arc<McpManager>,
|
||||
pub(crate) file_watcher: Arc<FileWatcher>,
|
||||
pub(crate) agent_control: AgentControl,
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pub(crate) network_proxy_spec: Option<Arc<NetworkProxySpec>>,
|
||||
pub(crate) network_proxy: Option<StartedNetworkProxy>,
|
||||
pub(crate) skill_network_proxy_cache: Arc<SkillNetworkProxyCache>,
|
||||
pub(crate) network_approval: Arc<NetworkApprovalService>,
|
||||
pub(crate) state_db: Option<StateDbHandle>,
|
||||
/// Session-scoped model client shared across turns.
|
||||
|
||||
@@ -23,7 +23,6 @@ use crate::protocol::SessionConfiguredEvent;
|
||||
use crate::rollout::RolloutRecorder;
|
||||
use crate::rollout::truncation;
|
||||
use crate::shell_snapshot::ShellSnapshot;
|
||||
use crate::skill_network_proxy_cache::SkillNetworkProxyCache;
|
||||
use crate::skills::SkillsManager;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::config_types::CollaborationModeMask;
|
||||
@@ -157,7 +156,6 @@ pub(crate) struct ThreadManagerState {
|
||||
plugins_manager: Arc<PluginsManager>,
|
||||
mcp_manager: Arc<McpManager>,
|
||||
file_watcher: Arc<FileWatcher>,
|
||||
skill_network_proxy_cache: Arc<SkillNetworkProxyCache>,
|
||||
session_source: SessionSource,
|
||||
// Captures submitted ops for testing purpose when test mode is enabled.
|
||||
ops_log: Option<SharedCapturedOps>,
|
||||
@@ -185,7 +183,6 @@ impl ThreadManager {
|
||||
config.bundled_skills_enabled(),
|
||||
));
|
||||
let file_watcher = build_file_watcher(codex_home.clone(), Arc::clone(&skills_manager));
|
||||
let skill_network_proxy_cache = Arc::new(SkillNetworkProxyCache::new());
|
||||
Self {
|
||||
state: Arc::new(ThreadManagerState {
|
||||
threads: Arc::new(RwLock::new(HashMap::new())),
|
||||
@@ -201,7 +198,6 @@ impl ThreadManager {
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
file_watcher,
|
||||
skill_network_proxy_cache,
|
||||
auth_manager,
|
||||
session_source,
|
||||
ops_log: should_use_test_thread_manager_behavior()
|
||||
@@ -248,7 +244,6 @@ impl ThreadManager {
|
||||
/*bundled_skills_enabled*/ true,
|
||||
));
|
||||
let file_watcher = build_file_watcher(codex_home.clone(), Arc::clone(&skills_manager));
|
||||
let skill_network_proxy_cache = Arc::new(SkillNetworkProxyCache::new());
|
||||
Self {
|
||||
state: Arc::new(ThreadManagerState {
|
||||
threads: Arc::new(RwLock::new(HashMap::new())),
|
||||
@@ -262,7 +257,6 @@ impl ThreadManager {
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
file_watcher,
|
||||
skill_network_proxy_cache,
|
||||
auth_manager,
|
||||
session_source: SessionSource::Exec,
|
||||
ops_log: should_use_test_thread_manager_behavior()
|
||||
@@ -764,7 +758,6 @@ impl ThreadManagerState {
|
||||
plugins_manager: Arc::clone(&self.plugins_manager),
|
||||
mcp_manager: Arc::clone(&self.mcp_manager),
|
||||
file_watcher: Arc::clone(&self.file_watcher),
|
||||
skill_network_proxy_cache: Arc::clone(&self.skill_network_proxy_cache),
|
||||
conversation_history: initial_history,
|
||||
session_source,
|
||||
agent_control,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use async_trait::async_trait;
|
||||
use codex_environment::ExecutorFileSystem;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::ImageDetail;
|
||||
@@ -13,6 +12,7 @@ use crate::function_tool::FunctionCallError;
|
||||
use crate::original_image_detail::can_request_original_image_detail;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::ViewImageToolCallEvent;
|
||||
use crate::sandboxed_fs;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
@@ -92,36 +92,6 @@ impl ToolHandler for ViewImageHandler {
|
||||
AbsolutePathBuf::try_from(turn.resolve_path(Some(args.path))).map_err(|error| {
|
||||
FunctionCallError::RespondToModel(format!("unable to resolve image path: {error}"))
|
||||
})?;
|
||||
|
||||
let metadata = turn
|
||||
.environment
|
||||
.get_filesystem()
|
||||
.get_metadata(&abs_path)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"unable to locate image at `{}`: {error}",
|
||||
abs_path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
if !metadata.is_file {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"image path `{}` is not a file",
|
||||
abs_path.display()
|
||||
)));
|
||||
}
|
||||
let file_bytes = turn
|
||||
.environment
|
||||
.get_filesystem()
|
||||
.read_file(&abs_path)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"unable to read image at `{}`: {error}",
|
||||
abs_path.display()
|
||||
))
|
||||
})?;
|
||||
let event_path = abs_path.to_path_buf();
|
||||
|
||||
let can_request_original_detail =
|
||||
@@ -134,10 +104,18 @@ impl ToolHandler for ViewImageHandler {
|
||||
PromptImageMode::ResizeToFit
|
||||
};
|
||||
let image_detail = use_original_detail.then_some(ImageDetail::Original);
|
||||
let image_bytes = sandboxed_fs::read_bytes(&session, &turn, abs_path.as_path())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
FunctionCallError::RespondToModel(render_view_image_read_error(
|
||||
abs_path.as_path(),
|
||||
&error,
|
||||
))
|
||||
})?;
|
||||
|
||||
let content = local_image_content_items_with_label_number(
|
||||
abs_path.as_path(),
|
||||
file_bytes,
|
||||
image_bytes,
|
||||
/*label_number*/ None,
|
||||
image_mode,
|
||||
)
|
||||
@@ -165,3 +143,30 @@ impl ToolHandler for ViewImageHandler {
|
||||
Ok(FunctionToolOutput::from_content(content, Some(true)))
|
||||
}
|
||||
}
|
||||
|
||||
fn render_view_image_read_error(
|
||||
path: &std::path::Path,
|
||||
error: &sandboxed_fs::SandboxedFsError,
|
||||
) -> String {
|
||||
let operation_message = error
|
||||
.operation_error_message()
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| error.to_string());
|
||||
match error.operation_error_kind() {
|
||||
Some(codex_fs_ops::FsErrorKind::IsADirectory) => {
|
||||
format!("image path `{}` is not a file", path.display())
|
||||
}
|
||||
Some(codex_fs_ops::FsErrorKind::NotFound) => {
|
||||
format!(
|
||||
"unable to locate image at `{}`: {operation_message}",
|
||||
path.display()
|
||||
)
|
||||
}
|
||||
Some(_) | None => {
|
||||
format!(
|
||||
"unable to read image at `{}`: {operation_message}",
|
||||
path.display()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ use codex_shell_escalation::ShellCommandExecutor;
|
||||
use codex_shell_escalation::Stopwatch;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -144,7 +143,6 @@ pub(super) async fn try_run_zsh_fork(
|
||||
ctx.session.services.exec_policy.current().as_ref().clone(),
|
||||
));
|
||||
let command_executor = CoreShellCommandExecutor {
|
||||
session: Some(Arc::clone(&ctx.session)),
|
||||
command,
|
||||
cwd: sandbox_cwd,
|
||||
sandbox_policy,
|
||||
@@ -251,7 +249,6 @@ pub(crate) async fn prepare_unified_exec_zsh_fork(
|
||||
ctx.session.services.exec_policy.current().as_ref().clone(),
|
||||
));
|
||||
let command_executor = CoreShellCommandExecutor {
|
||||
session: Some(Arc::clone(&ctx.session)),
|
||||
command: exec_request.command.clone(),
|
||||
cwd: exec_request.cwd.clone(),
|
||||
sandbox_policy: exec_request.sandbox_policy.clone(),
|
||||
@@ -487,7 +484,26 @@ impl CoreShellActionProvider {
|
||||
/// an absolute path. The idea is that we check to see whether it matches
|
||||
/// any skills.
|
||||
async fn find_skill(&self, program: &AbsolutePathBuf) -> Option<SkillMetadata> {
|
||||
find_skill_for_program(self.session.as_ref(), self.turn.cwd.as_path(), program).await
|
||||
let force_reload = false;
|
||||
let skills_outcome = self
|
||||
.session
|
||||
.services
|
||||
.skills_manager
|
||||
.skills_for_cwd(&self.turn.cwd, force_reload)
|
||||
.await;
|
||||
|
||||
let program_path = program.as_path();
|
||||
for skill in skills_outcome.skills {
|
||||
// We intentionally ignore "enabled" status here for now.
|
||||
let Some(skill_root) = skill.path_to_skills_md.parent() else {
|
||||
continue;
|
||||
};
|
||||
if program_path.starts_with(skill_root.join("scripts")) {
|
||||
return Some(skill);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -830,7 +846,6 @@ fn commands_for_intercepted_exec_policy(
|
||||
}
|
||||
|
||||
struct CoreShellCommandExecutor {
|
||||
session: Option<Arc<crate::codex::Session>>,
|
||||
command: Vec<String>,
|
||||
cwd: PathBuf,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
@@ -854,7 +869,6 @@ struct PrepareSandboxedExecParams<'a> {
|
||||
command: Vec<String>,
|
||||
workdir: &'a AbsolutePathBuf,
|
||||
env: HashMap<String, String>,
|
||||
network: Option<codex_network_proxy::NetworkProxy>,
|
||||
sandbox_policy: &'a SandboxPolicy,
|
||||
file_system_sandbox_policy: &'a FileSystemSandboxPolicy,
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
@@ -937,12 +951,10 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
|
||||
arg0: Some(first_arg.clone()),
|
||||
},
|
||||
EscalationExecution::TurnDefault => {
|
||||
let network = self.network_for_program(program).await?;
|
||||
self.prepare_sandboxed_exec(PrepareSandboxedExecParams {
|
||||
command,
|
||||
workdir,
|
||||
env,
|
||||
network,
|
||||
sandbox_policy: &self.sandbox_policy,
|
||||
file_system_sandbox_policy: &self.file_system_sandbox_policy,
|
||||
network_sandbox_policy: self.network_sandbox_policy,
|
||||
@@ -958,12 +970,10 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
|
||||
)) => {
|
||||
// Merge additive permissions into the existing turn/request sandbox policy.
|
||||
// On macOS, additional profile extensions are unioned with the turn defaults.
|
||||
let network = self.network_for_program(program).await?;
|
||||
self.prepare_sandboxed_exec(PrepareSandboxedExecParams {
|
||||
command,
|
||||
workdir,
|
||||
env,
|
||||
network,
|
||||
sandbox_policy: &self.sandbox_policy,
|
||||
file_system_sandbox_policy: &self.file_system_sandbox_policy,
|
||||
network_sandbox_policy: self.network_sandbox_policy,
|
||||
@@ -976,12 +986,10 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
|
||||
}
|
||||
EscalationExecution::Permissions(EscalationPermissions::Permissions(permissions)) => {
|
||||
// Use a fully specified sandbox policy instead of merging into the turn policy.
|
||||
let network = self.network_for_program(program).await?;
|
||||
self.prepare_sandboxed_exec(PrepareSandboxedExecParams {
|
||||
command,
|
||||
workdir,
|
||||
env,
|
||||
network,
|
||||
sandbox_policy: &permissions.sandbox_policy,
|
||||
file_system_sandbox_policy: &permissions.file_system_sandbox_policy,
|
||||
network_sandbox_policy: permissions.network_sandbox_policy,
|
||||
@@ -1008,7 +1016,6 @@ impl CoreShellCommandExecutor {
|
||||
command,
|
||||
workdir,
|
||||
env,
|
||||
network,
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
@@ -1047,8 +1054,8 @@ impl CoreShellCommandExecutor {
|
||||
file_system_policy: file_system_sandbox_policy,
|
||||
network_policy: network_sandbox_policy,
|
||||
sandbox,
|
||||
enforce_managed_network: network.is_some(),
|
||||
network: network.as_ref(),
|
||||
enforce_managed_network: self.network.is_some(),
|
||||
network: self.network.as_ref(),
|
||||
sandbox_policy_cwd: &self.sandbox_policy_cwd,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions,
|
||||
@@ -1068,26 +1075,6 @@ impl CoreShellCommandExecutor {
|
||||
arg0: exec_request.arg0,
|
||||
})
|
||||
}
|
||||
|
||||
async fn network_for_program(
|
||||
&self,
|
||||
program: &AbsolutePathBuf,
|
||||
) -> anyhow::Result<Option<codex_network_proxy::NetworkProxy>> {
|
||||
let Some(session) = self.session.as_ref() else {
|
||||
return Ok(self.network.clone());
|
||||
};
|
||||
let Some(skill) =
|
||||
find_skill_for_program(session.as_ref(), self.sandbox_policy_cwd.as_path(), program)
|
||||
.await
|
||||
else {
|
||||
return Ok(self.network.clone());
|
||||
};
|
||||
if let Some(network) = session.get_or_start_skill_network_proxy(&skill).await? {
|
||||
Ok(Some(network))
|
||||
} else {
|
||||
Ok(self.network.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
@@ -1163,31 +1150,6 @@ fn join_program_and_argv(program: &AbsolutePathBuf, argv: &[String]) -> Vec<Stri
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
async fn find_skill_for_program(
|
||||
session: &crate::codex::Session,
|
||||
cwd: &Path,
|
||||
program: &AbsolutePathBuf,
|
||||
) -> Option<SkillMetadata> {
|
||||
let force_reload = false;
|
||||
let skills_outcome = session
|
||||
.services
|
||||
.skills_manager
|
||||
.skills_for_cwd(cwd, force_reload)
|
||||
.await;
|
||||
|
||||
let program_path = program.as_path();
|
||||
for skill in skills_outcome.skills {
|
||||
let Some(skill_root) = skill.path_to_skills_md.parent() else {
|
||||
continue;
|
||||
};
|
||||
if program_path.starts_with(skill_root.join("scripts")) {
|
||||
return Some(skill);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "unix_escalation_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -651,7 +651,6 @@ host_executable(name = "git", paths = ["{allowed_git_literal}"])
|
||||
async fn prepare_escalated_exec_turn_default_preserves_macos_seatbelt_extensions() {
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir()).unwrap();
|
||||
let executor = CoreShellCommandExecutor {
|
||||
session: None,
|
||||
command: vec!["echo".to_string(), "ok".to_string()],
|
||||
cwd: cwd.to_path_buf(),
|
||||
env: HashMap::new(),
|
||||
@@ -704,7 +703,6 @@ async fn prepare_escalated_exec_turn_default_preserves_macos_seatbelt_extensions
|
||||
async fn prepare_escalated_exec_permissions_preserve_macos_seatbelt_extensions() {
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir()).unwrap();
|
||||
let executor = CoreShellCommandExecutor {
|
||||
session: None,
|
||||
command: vec!["echo".to_string(), "ok".to_string()],
|
||||
cwd: cwd.to_path_buf(),
|
||||
env: HashMap::new(),
|
||||
@@ -780,7 +778,6 @@ async fn prepare_escalated_exec_permission_profile_unions_turn_and_requested_mac
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir()).unwrap();
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
let executor = CoreShellCommandExecutor {
|
||||
session: None,
|
||||
command: vec!["echo".to_string(), "ok".to_string()],
|
||||
cwd: cwd.to_path_buf(),
|
||||
env: HashMap::new(),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::config::Constrained;
|
||||
use codex_core::features::Feature;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::openai_models::ConfigShellToolType;
|
||||
@@ -13,9 +14,15 @@ use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::openai_models::ReasoningEffortPreset;
|
||||
use codex_protocol::openai_models::TruncationPolicyConfig;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::FileSystemSpecialPath;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::ReadOnlyAccess;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses;
|
||||
@@ -1244,6 +1251,109 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn view_image_tool_respects_filesystem_sandbox() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let sandbox_policy_for_config = SandboxPolicy::ReadOnly {
|
||||
access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: Vec::new(),
|
||||
},
|
||||
network_access: false,
|
||||
};
|
||||
let mut builder = test_codex().with_config({
|
||||
let sandbox_policy_for_config = sandbox_policy_for_config.clone();
|
||||
move |config| {
|
||||
config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy_for_config);
|
||||
config.permissions.file_system_sandbox_policy =
|
||||
FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::Minimal,
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::CurrentWorkingDirectory,
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
let TestCodex {
|
||||
codex,
|
||||
config,
|
||||
cwd,
|
||||
session_configured,
|
||||
..
|
||||
} = builder.build(&server).await?;
|
||||
|
||||
let outside_dir = tempfile::tempdir()?;
|
||||
let abs_path = outside_dir.path().join("blocked.png");
|
||||
let image = ImageBuffer::from_pixel(256, 128, Rgba([10u8, 20, 30, 255]));
|
||||
image.save(&abs_path)?;
|
||||
|
||||
let call_id = "view-image-sandbox-denied";
|
||||
let arguments = serde_json::json!({ "path": abs_path }).to_string();
|
||||
|
||||
let first_response = sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_function_call(call_id, "view_image", &arguments),
|
||||
ev_completed("resp-1"),
|
||||
]);
|
||||
responses::mount_sse_once(&server, first_response).await;
|
||||
|
||||
let second_response = sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]);
|
||||
let mock = responses::mount_sse_once(&server, second_response).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "please attach the outside image".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
service_tier: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let request = mock.single_request();
|
||||
assert!(
|
||||
request.inputs_of_type("input_image").is_empty(),
|
||||
"sandbox-denied image should not produce an input_image message"
|
||||
);
|
||||
let output_text = request
|
||||
.function_call_output_content_and_success(call_id)
|
||||
.and_then(|(content, _)| content)
|
||||
.expect("output text present");
|
||||
let expected_prefix = format!("unable to read image at `{}`:", abs_path.display());
|
||||
assert!(
|
||||
output_text.starts_with(&expected_prefix),
|
||||
"expected sandbox denial prefix `{expected_prefix}` but got `{output_text}`"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
6
codex-rs/fs-ops/BUILD.bazel
Normal file
6
codex-rs/fs-ops/BUILD.bazel
Normal file
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "fs-ops",
|
||||
crate_name = "codex_fs_ops",
|
||||
)
|
||||
22
codex-rs/fs-ops/Cargo.toml
Normal file
22
codex-rs/fs-ops/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "codex-fs-ops"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "codex_fs_ops"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
40
codex-rs/fs-ops/src/command.rs
Normal file
40
codex-rs/fs-ops/src/command.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FsCommand {
|
||||
ReadBytes { path: PathBuf },
|
||||
ReadText { path: PathBuf },
|
||||
}
|
||||
|
||||
pub fn parse_command_from_args(
|
||||
mut args: impl Iterator<Item = OsString>,
|
||||
) -> Result<FsCommand, String> {
|
||||
let Some(operation) = args.next() else {
|
||||
return Err("missing operation".to_string());
|
||||
};
|
||||
let Some(operation) = operation.to_str() else {
|
||||
return Err("operation must be valid UTF-8".to_string());
|
||||
};
|
||||
let Some(path) = args.next() else {
|
||||
return Err(format!("missing path for operation `{operation}`"));
|
||||
};
|
||||
if args.next().is_some() {
|
||||
return Err(format!(
|
||||
"unexpected extra arguments for operation `{operation}`"
|
||||
));
|
||||
}
|
||||
|
||||
let path = PathBuf::from(path);
|
||||
match operation {
|
||||
"read_bytes" => Ok(FsCommand::ReadBytes { path }),
|
||||
"read_text" => Ok(FsCommand::ReadText { path }),
|
||||
_ => Err(format!(
|
||||
"unsupported filesystem operation `{operation}`; expected one of `read_bytes`, `read_text`"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "command_tests.rs"]
|
||||
mod tests;
|
||||
20
codex-rs/fs-ops/src/command_tests.rs
Normal file
20
codex-rs/fs-ops/src/command_tests.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use super::FsCommand;
|
||||
use super::parse_command_from_args;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn parse_read_bytes_command() {
|
||||
let command = parse_command_from_args(
|
||||
["read_bytes", "/tmp/example.png"]
|
||||
.into_iter()
|
||||
.map(Into::into),
|
||||
)
|
||||
.expect("command should parse");
|
||||
|
||||
assert_eq!(
|
||||
command,
|
||||
FsCommand::ReadBytes {
|
||||
path: "/tmp/example.png".into(),
|
||||
}
|
||||
);
|
||||
}
|
||||
3
codex-rs/fs-ops/src/constants.rs
Normal file
3
codex-rs/fs-ops/src/constants.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
/// Special argv[1] flag used when the Codex executable self-invokes to run the
|
||||
/// internal sandbox-backed filesystem helper path.
|
||||
pub const CODEX_CORE_FS_OPS_ARG1: &str = "--codex-run-as-fs-ops";
|
||||
70
codex-rs/fs-ops/src/error.rs
Normal file
70
codex-rs/fs-ops/src/error.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::io::ErrorKind;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FsErrorKind {
|
||||
NotFound,
|
||||
PermissionDenied,
|
||||
IsADirectory,
|
||||
InvalidData,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl From<ErrorKind> for FsErrorKind {
|
||||
fn from(value: ErrorKind) -> Self {
|
||||
match value {
|
||||
ErrorKind::NotFound => Self::NotFound,
|
||||
ErrorKind::PermissionDenied => Self::PermissionDenied,
|
||||
ErrorKind::IsADirectory => Self::IsADirectory,
|
||||
ErrorKind::InvalidData => Self::InvalidData,
|
||||
_ => Self::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FsErrorKind {
|
||||
pub fn to_io_error_kind(&self) -> ErrorKind {
|
||||
match self {
|
||||
Self::NotFound => ErrorKind::NotFound,
|
||||
Self::PermissionDenied => ErrorKind::PermissionDenied,
|
||||
Self::IsADirectory => ErrorKind::IsADirectory,
|
||||
Self::InvalidData => ErrorKind::InvalidData,
|
||||
Self::Other => ErrorKind::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FsError {
|
||||
pub kind: FsErrorKind,
|
||||
pub message: String,
|
||||
pub raw_os_error: Option<i32>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for FsError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl FsError {
|
||||
pub fn to_io_error(&self) -> std::io::Error {
|
||||
if let Some(raw_os_error) = self.raw_os_error {
|
||||
std::io::Error::from_raw_os_error(raw_os_error)
|
||||
} else {
|
||||
std::io::Error::new(self.kind.to_io_error_kind(), self.message.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for FsError {
|
||||
fn from(error: std::io::Error) -> Self {
|
||||
Self {
|
||||
kind: error.kind().into(),
|
||||
message: error.to_string(),
|
||||
raw_os_error: error.raw_os_error(),
|
||||
}
|
||||
}
|
||||
}
|
||||
16
codex-rs/fs-ops/src/lib.rs
Normal file
16
codex-rs/fs-ops/src/lib.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
mod command;
|
||||
mod constants;
|
||||
mod error;
|
||||
mod response;
|
||||
mod runner;
|
||||
|
||||
pub use command::FsCommand;
|
||||
pub use command::parse_command_from_args;
|
||||
pub use constants::CODEX_CORE_FS_OPS_ARG1;
|
||||
pub use error::FsError;
|
||||
pub use error::FsErrorKind;
|
||||
pub use response::FsPayload;
|
||||
pub use response::FsResponse;
|
||||
pub use runner::execute;
|
||||
pub use runner::run_from_args;
|
||||
pub use runner::write_response;
|
||||
17
codex-rs/fs-ops/src/response.rs
Normal file
17
codex-rs/fs-ops/src/response.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use crate::FsError;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum FsPayload {
|
||||
Bytes { base64: String },
|
||||
Text { text: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "status", rename_all = "snake_case")]
|
||||
pub enum FsResponse {
|
||||
Success { payload: FsPayload },
|
||||
Error { error: FsError },
|
||||
}
|
||||
50
codex-rs/fs-ops/src/runner.rs
Normal file
50
codex-rs/fs-ops/src/runner.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use crate::FsCommand;
|
||||
use crate::FsPayload;
|
||||
use crate::FsResponse;
|
||||
use crate::parse_command_from_args;
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use std::ffi::OsString;
|
||||
use std::io::Write;
|
||||
|
||||
pub fn run_from_args(args: impl Iterator<Item = OsString>) -> Result<()> {
|
||||
let command = parse_command_from_args(args).map_err(anyhow::Error::msg)?;
|
||||
let response = execute(command);
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
write_response(&mut stdout, &response)
|
||||
}
|
||||
|
||||
pub fn execute(command: FsCommand) -> FsResponse {
|
||||
match command {
|
||||
FsCommand::ReadBytes { path } => match std::fs::read(&path) {
|
||||
Ok(bytes) => FsResponse::Success {
|
||||
payload: FsPayload::Bytes {
|
||||
base64: BASE64_STANDARD.encode(bytes),
|
||||
},
|
||||
},
|
||||
Err(error) => FsResponse::Error {
|
||||
error: error.into(),
|
||||
},
|
||||
},
|
||||
FsCommand::ReadText { path } => match std::fs::read_to_string(&path) {
|
||||
Ok(text) => FsResponse::Success {
|
||||
payload: FsPayload::Text { text },
|
||||
},
|
||||
Err(error) => FsResponse::Error {
|
||||
error: error.into(),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_response(stdout: &mut impl Write, response: &FsResponse) -> Result<()> {
|
||||
serde_json::to_writer(&mut *stdout, response).context("failed to serialize fs response")?;
|
||||
writeln!(stdout).context("failed to terminate fs response with newline")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "runner_tests.rs"]
|
||||
mod tests;
|
||||
42
codex-rs/fs-ops/src/runner_tests.rs
Normal file
42
codex-rs/fs-ops/src/runner_tests.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use super::execute;
|
||||
use crate::FsCommand;
|
||||
use crate::FsErrorKind;
|
||||
use crate::FsPayload;
|
||||
use crate::FsResponse;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn read_text_returns_text_payload() {
|
||||
let tempdir = tempdir().expect("tempdir");
|
||||
let path = tempdir.path().join("note.txt");
|
||||
std::fs::write(&path, "hello").expect("write test file");
|
||||
|
||||
let response = execute(FsCommand::ReadText { path });
|
||||
|
||||
assert_eq!(
|
||||
response,
|
||||
FsResponse::Success {
|
||||
payload: FsPayload::Text {
|
||||
text: "hello".to_string(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_bytes_reports_directory_error() {
|
||||
let tempdir = tempdir().expect("tempdir");
|
||||
|
||||
let response = execute(FsCommand::ReadBytes {
|
||||
path: tempdir.path().to_path_buf(),
|
||||
});
|
||||
|
||||
let FsResponse::Error { error } = response else {
|
||||
panic!("expected error response");
|
||||
};
|
||||
#[cfg(target_os = "windows")]
|
||||
assert_eq!(error.kind, FsErrorKind::PermissionDenied);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
assert_eq!(error.kind, FsErrorKind::IsADirectory);
|
||||
}
|
||||
@@ -59,7 +59,6 @@ pub fn load_for_prompt_bytes(
|
||||
mode: PromptImageMode,
|
||||
) -> Result<EncodedImage, ImageProcessingError> {
|
||||
let path_buf = path.to_path_buf();
|
||||
|
||||
let key = ImageCacheKey {
|
||||
digest: sha1_digest(&file_bytes),
|
||||
mode,
|
||||
|
||||
Reference in New Issue
Block a user