Merge branch 'main' into dev/sayan/namespace-refactor-2

This commit is contained in:
sayan-oai
2026-04-13 19:47:53 +08:00
committed by GitHub
144 changed files with 6960 additions and 1609 deletions
+3
View File
@@ -10,6 +10,7 @@ ENV TZ="$TZ"
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# Devcontainers run as a non-root user, so enable bubblewrap's setuid mode.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
@@ -37,6 +38,8 @@ RUN apt-get update \
ipset \
iptables \
aggregate \
bubblewrap \
&& chmod u+s /usr/bin/bwrap \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
+4
View File
@@ -14,6 +14,8 @@ Use `devcontainer.json` when you are developing Codex itself. This is the same l
Use `devcontainer.secure.json` when you want a stricter runtime profile for running Codex inside a project container:
- installs the Codex CLI plus common build tools
- installs bubblewrap in setuid mode for Codex's Linux sandbox
- disables Docker's outer seccomp and AppArmor profiles so bubblewrap can construct Codex's inner sandbox
- enables firewall startup with an allowlist-driven outbound policy
- blocks IPv6 by default so the allowlist cannot be bypassed over AAAA routes
- requires `NET_ADMIN` and `NET_RAW` so the firewall can be installed at startup
@@ -43,3 +45,5 @@ Note that `/workspace/target` will contain the binaries built for your host plat
For arm64, specify `--platform=linux/arm64` instead for both `docker build` and `docker run`.
Currently, the contributor `Dockerfile` works for both x64 and arm64 Linux, though you need to run `rustup target add x86_64-unknown-linux-musl` yourself to install the musl toolchain for x64.
The secure profile's capability, seccomp, and AppArmor options are required when you want Codex's bubblewrap sandbox to run inside Docker as the non-root devcontainer user. Without them, Docker's default runtime profile can block bubblewrap's namespace setup before Codex's own seccomp filter is installed. This keeps the Docker relaxation explicit in the profile that is meant to run Codex inside a project container, while the default contributor profile stays lightweight.
+7
View File
@@ -12,6 +12,13 @@
}
},
"runArgs": [
"--cap-add=SYS_ADMIN",
"--cap-add=SYS_CHROOT",
"--cap-add=SETUID",
"--cap-add=SETGID",
"--cap-add=SYS_PTRACE",
"--security-opt=seccomp=unconfined",
"--security-opt=apparmor=unconfined",
"--cap-add=NET_ADMIN",
"--cap-add=NET_RAW"
],
+3 -1
View File
@@ -2097,9 +2097,10 @@ dependencies = [
"arc-swap",
"async-trait",
"base64 0.22.1",
"clap",
"codex-app-server-protocol",
"codex-config",
"codex-protocol",
"codex-sandboxing",
"codex-utils-absolute-path",
"codex-utils-cargo-bin",
"codex-utils-pty",
@@ -2107,6 +2108,7 @@ dependencies = [
"pretty_assertions",
"serde",
"serde_json",
"serial_test",
"tempfile",
"test-case",
"thiserror 2.0.18",
@@ -105,6 +105,7 @@ fn sample_thread_start_response(thread_id: &str, ephemeral: bool, model: &str) -
model_provider: "openai".to_string(),
service_tier: None,
cwd: PathBuf::from("/tmp"),
instruction_sources: Vec::new(),
approval_policy: AppServerAskForApproval::OnFailure,
approvals_reviewer: AppServerApprovalsReviewer::User,
sandbox: AppServerSandboxPolicy::DangerFullAccess,
@@ -155,6 +156,7 @@ fn sample_thread_resume_response_with_source(
model_provider: "openai".to_string(),
service_tier: None,
cwd: PathBuf::from("/tmp"),
instruction_sources: Vec::new(),
approval_policy: AppServerAskForApproval::OnFailure,
approvals_reviewer: AppServerApprovalsReviewer::User,
sandbox: AppServerSandboxPolicy::DangerFullAccess,
+1
View File
@@ -44,6 +44,7 @@ use codex_core::config::Config;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::LoaderOverrides;
pub use codex_exec_server::EnvironmentManager;
pub use codex_exec_server::ExecServerRuntimePaths;
use codex_feedback::CodexFeedback;
use codex_protocol::protocol::SessionSource;
use serde::de::DeserializeOwned;
@@ -12806,6 +12806,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
@@ -14095,6 +14103,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
@@ -14386,6 +14402,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
@@ -10654,6 +10654,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
@@ -11943,6 +11951,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
@@ -12234,6 +12250,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
@@ -2187,6 +2187,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
@@ -2187,6 +2187,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
@@ -2187,6 +2187,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
@@ -8,7 +8,11 @@ import type { AskForApproval } from "./AskForApproval";
import type { SandboxPolicy } from "./SandboxPolicy";
import type { Thread } from "./Thread";
export type ThreadForkResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string, approvalPolicy: AskForApproval,
export type ThreadForkResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string,
/**
* Instruction source files currently loaded for this thread.
*/
instructionSources: Array<string>, approvalPolicy: AskForApproval,
/**
* Reviewer currently used for approval requests on this thread.
*/
@@ -8,7 +8,11 @@ import type { AskForApproval } from "./AskForApproval";
import type { SandboxPolicy } from "./SandboxPolicy";
import type { Thread } from "./Thread";
export type ThreadResumeResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string, approvalPolicy: AskForApproval,
export type ThreadResumeResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string,
/**
* Instruction source files currently loaded for this thread.
*/
instructionSources: Array<string>, approvalPolicy: AskForApproval,
/**
* Reviewer currently used for approval requests on this thread.
*/
@@ -8,7 +8,11 @@ import type { AskForApproval } from "./AskForApproval";
import type { SandboxPolicy } from "./SandboxPolicy";
import type { Thread } from "./Thread";
export type ThreadStartResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string, approvalPolicy: AskForApproval,
export type ThreadStartResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string,
/**
* Instruction source files currently loaded for this thread.
*/
instructionSources: Array<string>, approvalPolicy: AskForApproval,
/**
* Reviewer currently used for approval requests on this thread.
*/
@@ -1405,6 +1405,7 @@ mod tests {
model_provider: "openai".to_string(),
service_tier: None,
cwd: PathBuf::from("/tmp"),
instruction_sources: vec![PathBuf::from("/tmp/AGENTS.md")],
approval_policy: v2::AskForApproval::OnFailure,
approvals_reviewer: v2::ApprovalsReviewer::User,
sandbox: v2::SandboxPolicy::DangerFullAccess,
@@ -1444,6 +1445,7 @@ mod tests {
"modelProvider": "openai",
"serviceTier": null,
"cwd": "/tmp",
"instructionSources": ["/tmp/AGENTS.md"],
"approvalPolicy": "on-failure",
"approvalsReviewer": "user",
"sandbox": {
@@ -2719,6 +2719,9 @@ pub struct ThreadStartResponse {
pub model_provider: String,
pub service_tier: Option<ServiceTier>,
pub cwd: PathBuf,
/// Instruction source files currently loaded for this thread.
#[serde(default)]
pub instruction_sources: Vec<PathBuf>,
#[experimental(nested)]
pub approval_policy: AskForApproval,
/// Reviewer currently used for approval requests on this thread.
@@ -2805,6 +2808,9 @@ pub struct ThreadResumeResponse {
pub model_provider: String,
pub service_tier: Option<ServiceTier>,
pub cwd: PathBuf,
/// Instruction source files currently loaded for this thread.
#[serde(default)]
pub instruction_sources: Vec<PathBuf>,
#[experimental(nested)]
pub approval_policy: AskForApproval,
/// Reviewer currently used for approval requests on this thread.
@@ -2882,6 +2888,9 @@ pub struct ThreadForkResponse {
pub model_provider: String,
pub service_tier: Option<ServiceTier>,
pub cwd: PathBuf,
/// Instruction source files currently loaded for this thread.
#[serde(default)]
pub instruction_sources: Vec<PathBuf>,
#[experimental(nested)]
pub approval_policy: AskForApproval,
/// Reviewer currently used for approval requests on this thread.
@@ -8506,6 +8515,50 @@ mod tests {
assert_eq!(serialized_without_override.get("serviceTier"), None);
}
#[test]
fn thread_lifecycle_responses_default_missing_instruction_sources() {
let response = json!({
"thread": {
"id": "thread-id",
"forkedFromId": null,
"preview": "",
"ephemeral": false,
"modelProvider": "openai",
"createdAt": 1,
"updatedAt": 1,
"status": { "type": "idle" },
"path": null,
"cwd": "/tmp",
"cliVersion": "0.0.0",
"source": "exec",
"agentNickname": null,
"agentRole": null,
"gitInfo": null,
"name": null,
"turns": []
},
"model": "gpt-5",
"modelProvider": "openai",
"serviceTier": null,
"cwd": "/tmp",
"approvalPolicy": "on-failure",
"approvalsReviewer": "user",
"sandbox": { "type": "dangerFullAccess" },
"reasoningEffort": null
});
let start: ThreadStartResponse =
serde_json::from_value(response.clone()).expect("thread/start response");
let resume: ThreadResumeResponse =
serde_json::from_value(response.clone()).expect("thread/resume response");
let fork: ThreadForkResponse =
serde_json::from_value(response).expect("thread/fork response");
assert_eq!(start.instruction_sources, Vec::<PathBuf>::new());
assert_eq!(resume.instruction_sources, Vec::<PathBuf>::new());
assert_eq!(fork.instruction_sources, Vec::<PathBuf>::new());
}
#[test]
fn turn_start_params_preserve_explicit_null_service_tier() {
let params: TurnStartParams = serde_json::from_value(json!({
@@ -239,6 +239,7 @@ use codex_core::sandboxing::SandboxPermissions;
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
use codex_core::windows_sandbox::WindowsSandboxSetupMode as CoreWindowsSandboxSetupMode;
use codex_core::windows_sandbox::WindowsSandboxSetupRequest;
use codex_exec_server::LOCAL_FS;
use codex_features::FEATURES;
use codex_features::Feature;
use codex_features::Stage;
@@ -491,6 +492,23 @@ pub(crate) struct CodexMessageProcessorArgs {
}
impl CodexMessageProcessor {
async fn instruction_sources_from_config(config: &Config) -> Vec<PathBuf> {
let mut paths: Vec<PathBuf> = config.user_instructions_path.iter().cloned().collect();
match codex_core::discover_project_doc_paths(config, LOCAL_FS.as_ref()).await {
Ok(project_doc_paths) => {
paths.extend(
project_doc_paths
.into_iter()
.map(|path| path.as_path().to_path_buf()),
);
}
Err(err) => {
tracing::warn!(error = %err, "failed to discover project docs for thread response");
}
}
paths
}
pub(crate) fn handle_config_mutation(&self) {
self.clear_plugin_related_caches();
}
@@ -2312,6 +2330,7 @@ impl CodexMessageProcessor {
};
}
let instruction_sources = Self::instruction_sources_from_config(&config).await;
let dynamic_tools = dynamic_tools.unwrap_or_default();
let core_dynamic_tools = if dynamic_tools.is_empty() {
Vec::new()
@@ -2443,6 +2462,7 @@ impl CodexMessageProcessor {
model_provider: config_snapshot.model_provider_id,
service_tier: config_snapshot.service_tier,
cwd: config_snapshot.cwd,
instruction_sources,
approval_policy: config_snapshot.approval_policy.into(),
approvals_reviewer: config_snapshot.approvals_reviewer.into(),
sandbox: config_snapshot.sandbox_policy.into(),
@@ -3881,6 +3901,7 @@ impl CodexMessageProcessor {
};
let fallback_model_provider = config.model_provider_id.clone();
let instruction_sources = Self::instruction_sources_from_config(&config).await;
let response_history = thread_history.clone();
match self
@@ -3961,6 +3982,7 @@ impl CodexMessageProcessor {
model_provider: session_configured.model_provider_id,
service_tier: session_configured.service_tier,
cwd: session_configured.cwd,
instruction_sources,
approval_policy: session_configured.approval_policy.into(),
approvals_reviewer: session_configured.approvals_reviewer.into(),
sandbox: session_configured.sandbox_policy.into(),
@@ -4119,6 +4141,12 @@ impl CodexMessageProcessor {
mismatch_details.join("; ")
);
}
let mut config_for_instruction_sources = self.config.as_ref().clone();
if let Ok(cwd) = AbsolutePathBuf::try_from(config_snapshot.cwd.clone()) {
config_for_instruction_sources.cwd = cwd;
}
let instruction_sources =
Self::instruction_sources_from_config(&config_for_instruction_sources).await;
let thread_summary = match load_thread_summary_for_rollout(
&self.config,
existing_thread_id,
@@ -4156,6 +4184,7 @@ impl CodexMessageProcessor {
request_id: request_id.clone(),
rollout_path: rollout_path.clone(),
config_snapshot,
instruction_sources,
thread_summary,
}),
);
@@ -4431,6 +4460,7 @@ impl CodexMessageProcessor {
};
let fallback_model_provider = config.model_provider_id.clone();
let instruction_sources = Self::instruction_sources_from_config(&config).await;
let NewThread {
thread_id,
@@ -4585,6 +4615,7 @@ impl CodexMessageProcessor {
model_provider: session_configured.model_provider_id,
service_tier: session_configured.service_tier,
cwd: session_configured.cwd,
instruction_sources,
approval_policy: session_configured.approval_policy.into(),
approvals_reviewer: session_configured.approvals_reviewer.into(),
sandbox: session_configured.sandbox_policy.into(),
@@ -8156,12 +8187,14 @@ async fn handle_pending_thread_resume_request(
reasoning_effort,
..
} = pending.config_snapshot;
let instruction_sources = pending.instruction_sources;
let response = ThreadResumeResponse {
thread,
model,
model_provider: model_provider_id,
service_tier,
cwd,
instruction_sources,
approval_policy: approval_policy.into(),
approvals_reviewer: approvals_reviewer.into(),
sandbox: sandbox_policy.into(),
+7 -4
View File
@@ -46,7 +46,7 @@ impl FsApi {
) -> Result<FsReadFileResponse, JSONRPCErrorError> {
let bytes = self
.file_system
.read_file(&params.path)
.read_file(&params.path, /*sandbox*/ None)
.await
.map_err(map_fs_error)?;
Ok(FsReadFileResponse {
@@ -64,7 +64,7 @@ impl FsApi {
))
})?;
self.file_system
.write_file(&params.path, bytes)
.write_file(&params.path, bytes, /*sandbox*/ None)
.await
.map_err(map_fs_error)?;
Ok(FsWriteFileResponse {})
@@ -80,6 +80,7 @@ impl FsApi {
CreateDirectoryOptions {
recursive: params.recursive.unwrap_or(true),
},
/*sandbox*/ None,
)
.await
.map_err(map_fs_error)?;
@@ -92,7 +93,7 @@ impl FsApi {
) -> Result<FsGetMetadataResponse, JSONRPCErrorError> {
let metadata = self
.file_system
.get_metadata(&params.path)
.get_metadata(&params.path, /*sandbox*/ None)
.await
.map_err(map_fs_error)?;
Ok(FsGetMetadataResponse {
@@ -109,7 +110,7 @@ impl FsApi {
) -> Result<FsReadDirectoryResponse, JSONRPCErrorError> {
let entries = self
.file_system
.read_directory(&params.path)
.read_directory(&params.path, /*sandbox*/ None)
.await
.map_err(map_fs_error)?;
Ok(FsReadDirectoryResponse {
@@ -135,6 +136,7 @@ impl FsApi {
recursive: params.recursive.unwrap_or(true),
force: params.force.unwrap_or(true),
},
/*sandbox*/ None,
)
.await
.map_err(map_fs_error)?;
@@ -152,6 +154,7 @@ impl FsApi {
CopyOptions {
recursive: params.recursive,
},
/*sandbox*/ None,
)
.await
.map_err(map_fs_error)?;
+7 -1
View File
@@ -44,6 +44,7 @@ use codex_core::check_execpolicy_for_warnings;
use codex_core::config_loader::ConfigLoadError;
use codex_core::config_loader::TextRange as CoreTextRange;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::ExecServerRuntimePaths;
use codex_feedback::CodexFeedback;
use codex_protocol::protocol::SessionSource;
use codex_state::log_db;
@@ -360,7 +361,12 @@ pub async fn run_main_with_transport(
session_source: SessionSource,
auth: AppServerWebsocketAuthSettings,
) -> IoResult<()> {
let environment_manager = Arc::new(EnvironmentManager::from_env());
let environment_manager = Arc::new(EnvironmentManager::from_env_with_runtime_paths(Some(
ExecServerRuntimePaths::from_optional_paths(
arg0_paths.codex_self_exe.clone(),
arg0_paths.codex_linux_sandbox_exe.clone(),
)?,
)));
let (transport_event_tx, mut transport_event_rx) =
mpsc::channel::<TransportEvent>(CHANNEL_CAPACITY);
let (outgoing_tx, mut outgoing_rx) = mpsc::channel::<OutgoingEnvelope>(CHANNEL_CAPACITY);
+1
View File
@@ -27,6 +27,7 @@ pub(crate) struct PendingThreadResumeRequest {
pub(crate) request_id: ConnectionRequestId,
pub(crate) rollout_path: PathBuf,
pub(crate) config_snapshot: ThreadConfigSnapshot,
pub(crate) instruction_sources: Vec<PathBuf>,
pub(crate) thread_summary: codex_app_server_protocol::Thread,
}
@@ -32,6 +32,7 @@ use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_config::types::AuthCredentialsStoreMode;
use core_test_support::assert_regex_match;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use rmcp::handler::server::ServerHandler;
@@ -274,8 +275,15 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> {
.get("output")
.and_then(Value::as_str)
.expect("function_call_output output should be a JSON string");
let payload = assert_regex_match(
r#"(?s)^Wall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n(.*)$"#,
output,
)
.get(1)
.expect("wall-time wrapped output should include payload")
.as_str();
assert_eq!(
serde_json::from_str::<Value>(output)?,
serde_json::from_str::<Value>(payload)?,
json!([{
"type": "text",
"text": "accepted"
@@ -661,7 +661,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
let connections = realtime_server.connections();
assert_eq!(connections.len(), 1);
let connection = &connections[0];
assert_eq!(connection.len(), 4);
assert_eq!(connection.len(), 3);
assert_eq!(
connection[0].body_json()["type"].as_str(),
Some("session.update")
@@ -679,10 +679,6 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
.as_str()
.context("expected websocket request type")?
.to_string(),
connection[3].body_json()["type"]
.as_str()
.context("expected websocket request type")?
.to_string(),
];
request_types.sort();
assert_eq!(
@@ -690,7 +686,6 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
[
"conversation.item.create".to_string(),
"input_audio_buffer.append".to_string(),
"response.create".to_string(),
]
);
@@ -1153,7 +1148,6 @@ async fn webrtc_v2_forwards_audio_and_text_between_client_and_sideband() -> Resu
"samples_per_channel": 512
}),
],
vec![],
])]),
)
.await?;
@@ -1185,7 +1179,6 @@ async fn webrtc_v2_forwards_audio_and_text_between_client_and_sideband() -> Resu
let requests = [
harness.sideband_outbound_request(/*request_index*/ 1).await,
harness.sideband_outbound_request(/*request_index*/ 2).await,
harness.sideband_outbound_request(/*request_index*/ 3).await,
];
assert!(
requests
@@ -1208,13 +1201,12 @@ async fn webrtc_v2_forwards_audio_and_text_between_client_and_sideband() -> Resu
Ok(())
}
/// Regression coverage for Realtime V2's single-active-response rule.
/// Regression coverage for Realtime V2 text input while a response is active.
///
/// The Realtime API rejects a new `response.create` while a default response is
/// still active, so the input task should queue the second create and flush it
/// only after the server sends `response.done` for the active response.
/// Text input is append-only, so app-server should send the user message without
/// requesting a new realtime response.
#[tokio::test]
async fn webrtc_v2_queues_text_response_create_while_response_is_active() -> Result<()> {
async fn webrtc_v2_text_input_is_append_only_while_response_is_active() -> Result<()> {
skip_if_no_network!(Ok(()));
// Phase 1: script a server-side response that becomes active after the first
@@ -1224,7 +1216,6 @@ async fn webrtc_v2_queues_text_response_create_while_response_is_active() -> Res
no_main_loop_responses(),
realtime_sideband(vec![realtime_sideband_connection(vec![
vec![session_updated("sess_v2_response_queue")],
vec![],
vec![
json!({
"type": "response.created",
@@ -1240,7 +1231,6 @@ async fn webrtc_v2_queues_text_response_create_while_response_is_active() -> Res
"type": "response.done",
"response": { "id": "resp_active" }
})],
vec![],
])]),
)
.await?;
@@ -1253,15 +1243,14 @@ async fn webrtc_v2_queues_text_response_create_while_response_is_active() -> Res
// notifications; they are the protocol frames app-server sends upstream.
assert_v2_session_update(&harness.sideband_outbound_request(/*request_index*/ 0).await)?;
// Phase 2: send the first text turn. It is safe to emit `response.create`
// immediately because no default response is active yet.
// Phase 2: send the first text turn. Text input is append-only, so this
// sends only the user text item.
let thread_id = started.started.thread_id.clone();
harness.append_text(thread_id.clone(), "first").await?;
assert_v2_user_text_item(
&harness.sideband_outbound_request(/*request_index*/ 1).await,
"first",
);
assert_v2_response_create(&harness.sideband_outbound_request(/*request_index*/ 2).await);
let transcript = harness
.read_notification::<ThreadRealtimeTranscriptUpdatedNotification>(
"thread/realtime/transcriptUpdated",
@@ -1270,39 +1259,28 @@ async fn webrtc_v2_queues_text_response_create_while_response_is_active() -> Res
assert_eq!(transcript.text, "active response started");
// Phase 3: send a second text turn while `resp_active` is still open. The
// user message must reach realtime, but `response.create` must not be sent
// yet or the Realtime API rejects it as an active-response conflict.
// user message must reach realtime without requesting another response.
harness.append_text(thread_id.clone(), "second").await?;
assert_v2_user_text_item(
&harness.sideband_outbound_request(/*request_index*/ 3).await,
&harness.sideband_outbound_request(/*request_index*/ 2).await,
"second",
);
// Phase 4: the audio input causes the scripted sideband stream to send
// `response.done`, which clears the active response and flushes the queued
// `response.create` for the second text turn.
// Phase 4: audio still forwards normally after text input.
harness.append_audio(thread_id).await?;
// This is the negative check: if the second text turn had emitted
// `response.create` immediately, request 4 would be that create instead of
// the audio append.
let audio = harness.sideband_outbound_request(/*request_index*/ 4).await;
let audio = harness.sideband_outbound_request(/*request_index*/ 3).await;
assert_eq!(audio["type"], "input_audio_buffer.append");
assert_eq!(audio["audio"], "BQYH");
assert_v2_response_create(&harness.sideband_outbound_request(/*request_index*/ 5).await);
harness.shutdown().await;
Ok(())
}
/// Regression coverage for the same queued `response.create` path when the
/// active Realtime V2 response is cancelled instead of completed.
///
/// `response.cancelled` should clear the active-response guard exactly like
/// `response.done`, so a text turn queued during the active response still gets
/// one deferred `response.create`.
/// Regression coverage for append-only Realtime V2 text input when the active
/// response is cancelled instead of completed.
#[tokio::test]
async fn webrtc_v2_flushes_queued_text_response_create_when_response_is_cancelled() -> Result<()> {
async fn webrtc_v2_text_input_is_append_only_when_response_is_cancelled() -> Result<()> {
skip_if_no_network!(Ok(()));
// Phase 1: script a server-side response that becomes active after the first
@@ -1312,7 +1290,6 @@ async fn webrtc_v2_flushes_queued_text_response_create_when_response_is_cancelle
no_main_loop_responses(),
realtime_sideband(vec![realtime_sideband_connection(vec![
vec![session_updated("sess_v2_response_cancel_queue")],
vec![],
vec![json!({
"type": "response.created",
"response": { "id": "resp_cancelled" }
@@ -1322,7 +1299,6 @@ async fn webrtc_v2_flushes_queued_text_response_create_when_response_is_cancelle
"type": "response.cancelled",
"response": { "id": "resp_cancelled" }
})],
vec![],
])]),
)
.await?;
@@ -1331,36 +1307,29 @@ async fn webrtc_v2_flushes_queued_text_response_create_when_response_is_cancelle
assert_eq!(started.started.version, RealtimeConversationVersion::V2);
assert_v2_session_update(&harness.sideband_outbound_request(/*request_index*/ 0).await)?;
// Phase 2: send the first text turn. It is safe to emit `response.create`
// immediately because no default response is active yet.
// Phase 2: send the first text turn. Text input is append-only, so this
// sends only the user text item.
let thread_id = started.started.thread_id.clone();
harness.append_text(thread_id.clone(), "first").await?;
assert_v2_user_text_item(
&harness.sideband_outbound_request(/*request_index*/ 1).await,
"first",
);
assert_v2_response_create(&harness.sideband_outbound_request(/*request_index*/ 2).await);
// Phase 3: send a second text turn while `resp_cancelled` is still open.
// The user message must reach realtime, but `response.create` stays queued.
// The user message must reach realtime without requesting another response.
harness.append_text(thread_id.clone(), "second").await?;
assert_v2_user_text_item(
&harness.sideband_outbound_request(/*request_index*/ 3).await,
&harness.sideband_outbound_request(/*request_index*/ 2).await,
"second",
);
// Phase 4: the audio input causes the scripted sideband stream to send
// `response.cancelled`, which clears the active response and flushes the
// queued `response.create` for the second text turn.
// Phase 4: audio still forwards normally after text input.
harness.append_audio(thread_id).await?;
// This is the negative check: if the second text turn had emitted
// `response.create` immediately, request 4 would be that create instead of
// the audio append.
let audio = harness.sideband_outbound_request(/*request_index*/ 4).await;
let audio = harness.sideband_outbound_request(/*request_index*/ 3).await;
assert_eq!(audio["type"], "input_audio_buffer.append");
assert_eq!(audio["audio"], "BQYH");
assert_v2_response_create(&harness.sideband_outbound_request(/*request_index*/ 5).await);
harness.shutdown().await;
Ok(())
@@ -29,6 +29,7 @@ use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -162,6 +163,64 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn thread_start_response_includes_loaded_instruction_sources() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?;
let global_agents_path = codex_home.path().join("AGENTS.md");
std::fs::write(&global_agents_path, "global instructions")?;
let workspace = TempDir::new()?;
let project_agents_path = workspace.path().join("AGENTS.md");
std::fs::write(&project_agents_path, "project instructions")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_thread_start_request(ThreadStartParams {
cwd: Some(workspace.path().display().to_string()),
..Default::default()
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadStartResponse {
instruction_sources,
..
} = to_response::<ThreadStartResponse>(response)?;
let instruction_sources = instruction_sources
.into_iter()
.map(normalize_path_for_comparison)
.collect::<Vec<_>>();
let expected_instruction_sources = vec![
std::fs::canonicalize(global_agents_path)?,
std::fs::canonicalize(project_agents_path)?,
]
.into_iter()
.map(normalize_path_for_comparison)
.collect::<Vec<_>>();
assert_eq!(instruction_sources, expected_instruction_sources);
Ok(())
}
#[cfg(windows)]
fn normalize_path_for_comparison(path: PathBuf) -> PathBuf {
let path = path.display().to_string();
PathBuf::from(path.strip_prefix(r"\\?\").unwrap_or(&path))
}
#[cfg(not(windows))]
fn normalize_path_for_comparison(path: PathBuf) -> PathBuf {
path
}
#[tokio::test]
async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
+17 -10
View File
@@ -135,6 +135,7 @@ pub async fn maybe_parse_apply_patch_verified(
argv: &[String],
cwd: &AbsolutePathBuf,
fs: &dyn ExecutorFileSystem,
sandbox: Option<&codex_exec_server::FileSystemSandboxContext>,
) -> MaybeApplyPatchVerified {
// Detect a raw patch body passed directly as the command or as the body of a shell
// script. In these cases, report an explicit error rather than applying the patch.
@@ -170,7 +171,7 @@ pub async fn maybe_parse_apply_patch_verified(
);
}
Hunk::DeleteFile { .. } => {
let content = match fs.read_file_text(&path).await {
let content = match fs.read_file_text(&path, sandbox).await {
Ok(content) => content,
Err(e) => {
return MaybeApplyPatchVerified::CorrectnessError(
@@ -192,7 +193,7 @@ pub async fn maybe_parse_apply_patch_verified(
let ApplyPatchFileUpdate {
unified_diff,
content: contents,
} = match unified_diff_from_chunks(&path, &chunks, fs).await {
} = match unified_diff_from_chunks(&path, &chunks, fs, sandbox).await {
Ok(diff) => diff,
Err(e) => {
return MaybeApplyPatchVerified::CorrectnessError(e);
@@ -467,7 +468,8 @@ mod tests {
maybe_parse_apply_patch_verified(
&args,
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
LOCAL_FS.as_ref()
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await,
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
@@ -483,7 +485,8 @@ mod tests {
maybe_parse_apply_patch_verified(
&args,
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
LOCAL_FS.as_ref()
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await,
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
@@ -693,9 +696,10 @@ PATCH"#,
};
let path_abs = path.as_path().abs();
let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref())
.await
.unwrap();
let diff =
unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
.await
.unwrap();
let expected_diff = r#"@@ -2,2 +2,2 @@
bar
-baz
@@ -731,9 +735,10 @@ PATCH"#,
};
let path_abs = path.as_path().abs();
let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref())
.await
.unwrap();
let diff =
unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
.await
.unwrap();
let expected_diff = r#"@@ -3 +3,2 @@
baz
+quux
@@ -770,6 +775,7 @@ PATCH"#,
&argv,
&AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(),
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await;
@@ -823,6 +829,7 @@ PATCH"#,
&argv,
&AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(),
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await;
let action = match result {
+77 -37
View File
@@ -12,6 +12,7 @@ use anyhow::Context;
use anyhow::Result;
use codex_exec_server::CreateDirectoryOptions;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::FileSystemSandboxContext;
use codex_exec_server::RemoveOptions;
use codex_utils_absolute_path::AbsolutePathBuf;
pub use parser::Hunk;
@@ -184,6 +185,7 @@ pub async fn apply_patch(
stdout: &mut impl std::io::Write,
stderr: &mut impl std::io::Write,
fs: &dyn ExecutorFileSystem,
sandbox: Option<&FileSystemSandboxContext>,
) -> Result<(), ApplyPatchError> {
let hunks = match parse_patch(patch) {
Ok(source) => source.hunks,
@@ -207,7 +209,7 @@ pub async fn apply_patch(
}
};
apply_hunks(&hunks, cwd, stdout, stderr, fs).await?;
apply_hunks(&hunks, cwd, stdout, stderr, fs, sandbox).await?;
Ok(())
}
@@ -219,9 +221,10 @@ pub async fn apply_hunks(
stdout: &mut impl std::io::Write,
stderr: &mut impl std::io::Write,
fs: &dyn ExecutorFileSystem,
sandbox: Option<&FileSystemSandboxContext>,
) -> Result<(), ApplyPatchError> {
// Delegate to a helper that applies each hunk to the filesystem.
match apply_hunks_to_files(hunks, cwd, fs).await {
match apply_hunks_to_files(hunks, cwd, fs, sandbox).await {
Ok(affected) => {
print_summary(&affected, stdout).map_err(ApplyPatchError::from)?;
Ok(())
@@ -257,6 +260,7 @@ async fn apply_hunks_to_files(
hunks: &[Hunk],
cwd: &AbsolutePathBuf,
fs: &dyn ExecutorFileSystem,
sandbox: Option<&FileSystemSandboxContext>,
) -> anyhow::Result<AffectedPaths> {
if hunks.is_empty() {
anyhow::bail!("No files were modified.");
@@ -271,23 +275,27 @@ async fn apply_hunks_to_files(
match hunk {
Hunk::AddFile { contents, .. } => {
if let Some(parent_abs) = path_abs.parent() {
fs.create_directory(&parent_abs, CreateDirectoryOptions { recursive: true })
.await
.with_context(|| {
format!(
"Failed to create parent directories for {}",
path_abs.display()
)
})?;
fs.create_directory(
&parent_abs,
CreateDirectoryOptions { recursive: true },
sandbox,
)
.await
.with_context(|| {
format!(
"Failed to create parent directories for {}",
path_abs.display()
)
})?;
}
fs.write_file(&path_abs, contents.clone().into_bytes())
fs.write_file(&path_abs, contents.clone().into_bytes(), sandbox)
.await
.with_context(|| format!("Failed to write file {}", path_abs.display()))?;
added.push(affected_path);
}
Hunk::DeleteFile { .. } => {
let result: io::Result<()> = async {
let metadata = fs.get_metadata(&path_abs).await?;
let metadata = fs.get_metadata(&path_abs, sandbox).await?;
if metadata.is_directory {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
@@ -300,6 +308,7 @@ async fn apply_hunks_to_files(
recursive: false,
force: false,
},
sandbox,
)
.await
}
@@ -311,13 +320,14 @@ async fn apply_hunks_to_files(
move_path, chunks, ..
} => {
let AppliedPatch { new_contents, .. } =
derive_new_contents_from_chunks(&path_abs, chunks, fs).await?;
derive_new_contents_from_chunks(&path_abs, chunks, fs, sandbox).await?;
if let Some(dest) = move_path {
let dest_abs = AbsolutePathBuf::resolve_path_against_base(dest, cwd);
if let Some(parent_abs) = dest_abs.parent() {
fs.create_directory(
&parent_abs,
CreateDirectoryOptions { recursive: true },
sandbox,
)
.await
.with_context(|| {
@@ -327,11 +337,11 @@ async fn apply_hunks_to_files(
)
})?;
}
fs.write_file(&dest_abs, new_contents.into_bytes())
fs.write_file(&dest_abs, new_contents.into_bytes(), sandbox)
.await
.with_context(|| format!("Failed to write file {}", dest_abs.display()))?;
let result: io::Result<()> = async {
let metadata = fs.get_metadata(&path_abs).await?;
let metadata = fs.get_metadata(&path_abs, sandbox).await?;
if metadata.is_directory {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
@@ -344,6 +354,7 @@ async fn apply_hunks_to_files(
recursive: false,
force: false,
},
sandbox,
)
.await
}
@@ -353,7 +364,7 @@ async fn apply_hunks_to_files(
})?;
modified.push(affected_path);
} else {
fs.write_file(&path_abs, new_contents.into_bytes())
fs.write_file(&path_abs, new_contents.into_bytes(), sandbox)
.await
.with_context(|| format!("Failed to write file {}", path_abs.display()))?;
modified.push(affected_path);
@@ -379,8 +390,9 @@ async fn derive_new_contents_from_chunks(
path_abs: &AbsolutePathBuf,
chunks: &[UpdateFileChunk],
fs: &dyn ExecutorFileSystem,
sandbox: Option<&FileSystemSandboxContext>,
) -> std::result::Result<AppliedPatch, ApplyPatchError> {
let original_contents = fs.read_file_text(path_abs).await.map_err(|err| {
let original_contents = fs.read_file_text(path_abs, sandbox).await.map_err(|err| {
ApplyPatchError::IoError(IoError {
context: format!("Failed to read file to update {}", path_abs.display()),
source: err,
@@ -540,8 +552,9 @@ pub async fn unified_diff_from_chunks(
path_abs: &AbsolutePathBuf,
chunks: &[UpdateFileChunk],
fs: &dyn ExecutorFileSystem,
sandbox: Option<&FileSystemSandboxContext>,
) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
unified_diff_from_chunks_with_context(path_abs, chunks, /*context*/ 1, fs).await
unified_diff_from_chunks_with_context(path_abs, chunks, /*context*/ 1, fs, sandbox).await
}
pub async fn unified_diff_from_chunks_with_context(
@@ -549,11 +562,12 @@ pub async fn unified_diff_from_chunks_with_context(
chunks: &[UpdateFileChunk],
context: usize,
fs: &dyn ExecutorFileSystem,
sandbox: Option<&FileSystemSandboxContext>,
) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
let AppliedPatch {
original_contents,
new_contents,
} = derive_new_contents_from_chunks(path_abs, chunks, fs).await?;
} = derive_new_contents_from_chunks(path_abs, chunks, fs, sandbox).await?;
let text_diff = TextDiff::from_lines(&original_contents, &new_contents);
let unified_diff = text_diff.unified_diff().context_radius(context).to_string();
Ok(ApplyPatchFileUpdate {
@@ -614,6 +628,7 @@ mod tests {
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await
.unwrap();
@@ -667,9 +682,16 @@ mod tests {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
apply_patch(&patch, &cwd, &mut stdout, &mut stderr, LOCAL_FS.as_ref())
.await
.unwrap();
apply_patch(
&patch,
&cwd,
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await
.unwrap();
assert_eq!(fs::read_to_string(&relative_add).unwrap(), "relative add\n");
assert_eq!(fs::read_to_string(&absolute_add).unwrap(), "absolute add\n");
@@ -709,6 +731,7 @@ mod tests {
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await
.unwrap();
@@ -744,6 +767,7 @@ mod tests {
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await
.unwrap();
@@ -783,6 +807,7 @@ mod tests {
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await
.unwrap();
@@ -831,6 +856,7 @@ mod tests {
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await
.unwrap();
@@ -888,6 +914,7 @@ mod tests {
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await
.unwrap();
@@ -931,6 +958,7 @@ mod tests {
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await
.unwrap();
@@ -973,6 +1001,7 @@ mod tests {
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await
.unwrap();
@@ -1019,9 +1048,14 @@ mod tests {
_ => panic!("Expected a single UpdateFile hunk"),
};
let path_abs = path.as_path().abs();
let diff = unified_diff_from_chunks(&path_abs, update_file_chunks, LOCAL_FS.as_ref())
.await
.unwrap();
let diff = unified_diff_from_chunks(
&path_abs,
update_file_chunks,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await
.unwrap();
let expected_diff = r#"@@ -1,4 +1,4 @@
foo
-bar
@@ -1061,9 +1095,10 @@ mod tests {
};
let path_abs = path.as_path().abs();
let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref())
.await
.unwrap();
let diff =
unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
.await
.unwrap();
let expected_diff = r#"@@ -1,2 +1,2 @@
-foo
+FOO
@@ -1101,9 +1136,10 @@ mod tests {
};
let path_abs = path.as_path().abs();
let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref())
.await
.unwrap();
let diff =
unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
.await
.unwrap();
let expected_diff = r#"@@ -2,2 +2,2 @@
bar
-baz
@@ -1139,9 +1175,10 @@ mod tests {
};
let path_abs = path.as_path().abs();
let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref())
.await
.unwrap();
let diff =
unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
.await
.unwrap();
let expected_diff = r#"@@ -3 +3,2 @@
baz
+quux
@@ -1188,9 +1225,10 @@ mod tests {
};
let path_abs = path.as_path().abs();
let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref())
.await
.unwrap();
let diff =
unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
.await
.unwrap();
let expected_diff = r#"@@ -1,6 +1,7 @@
a
@@ -1219,6 +1257,7 @@ mod tests {
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await
.unwrap();
@@ -1258,6 +1297,7 @@ g
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await;
assert!(result.is_err());
@@ -71,6 +71,7 @@ pub fn run_main() -> i32 {
&mut stdout,
&mut stderr,
codex_exec_server::LOCAL_FS.as_ref(),
/*sandbox*/ None,
)) {
Ok(()) => {
// Flush to ensure output ordering when used in pipelines.
+7 -2
View File
@@ -4,6 +4,7 @@ use std::path::Path;
use std::path::PathBuf;
use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1;
use codex_exec_server::CODEX_FS_HELPER_ARG1;
use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0;
use codex_utils_home_dir::find_codex_home;
#[cfg(unix)]
@@ -93,6 +94,9 @@ pub fn arg0_dispatch() -> Option<Arg0PathEntryGuard> {
}
let argv1 = args.next().unwrap_or_default();
if argv1 == CODEX_FS_HELPER_ARG1 {
codex_exec_server::run_fs_helper_main();
}
if argv1 == CODEX_CORE_APPLY_PATCH_ARG1 {
let patch_arg = args.next().and_then(|s| s.to_str().map(str::to_owned));
let exit_code = match patch_arg {
@@ -116,6 +120,7 @@ pub fn arg0_dispatch() -> Option<Arg0PathEntryGuard> {
&mut stdout,
&mut stderr,
codex_exec_server::LOCAL_FS.as_ref(),
/*sandbox*/ None,
)) {
Ok(()) => 0,
Err(_) => 1,
@@ -325,13 +330,13 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result<Arg0PathEntryGu
#[cfg(windows)]
{
let batch_script = path.join(format!("{filename}.bat"));
let exe = exe.display();
std::fs::write(
&batch_script,
format!(
r#"@echo off
"{}" {CODEX_CORE_APPLY_PATCH_ARG1} %*
"{exe}" {CODEX_CORE_APPLY_PATCH_ARG1} %*
"#,
exe.display()
),
)?;
}
+15 -4
View File
@@ -155,7 +155,7 @@ enum Subcommand {
#[clap(hide = true, name = "stdio-to-uds")]
StdioToUds(StdioToUdsCommand),
/// [EXPERIMENTAL] Run the standalone exec-server binary.
/// [EXPERIMENTAL] Run the standalone exec-server service.
ExecServer(ExecServerCommand),
/// Inspect feature flags.
@@ -1031,7 +1031,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
root_remote_auth_token_env.as_deref(),
"exec-server",
)?;
run_exec_server_command(cmd).await?;
run_exec_server_command(cmd, &arg0_paths).await?;
}
Some(Subcommand::Features(FeaturesCli { sub })) => match sub {
FeaturesSubcommand::List => {
@@ -1103,8 +1103,19 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
Ok(())
}
async fn run_exec_server_command(cmd: ExecServerCommand) -> anyhow::Result<()> {
codex_exec_server::run_main_with_listen_url(&cmd.listen)
async fn run_exec_server_command(
cmd: ExecServerCommand,
arg0_paths: &Arg0DispatchPaths,
) -> anyhow::Result<()> {
let codex_self_exe = arg0_paths
.codex_self_exe
.clone()
.ok_or_else(|| anyhow::anyhow!("Codex executable path is not configured"))?;
let runtime_paths = codex_exec_server::ExecServerRuntimePaths::new(
codex_self_exe,
arg0_paths.codex_linux_sandbox_exe.clone(),
)?;
codex_exec_server::run_main(&cmd.listen, runtime_paths)
.await
.map_err(anyhow::Error::from_boxed)
}
+1
View File
@@ -14,6 +14,7 @@ pub mod profile_toml;
mod project_root_markers;
mod requirements_exec_policy;
pub mod schema;
pub mod shell_environment;
mod skills_config;
mod state;
pub mod types;
+123
View File
@@ -0,0 +1,123 @@
use crate::types::EnvironmentVariablePattern;
use crate::types::ShellEnvironmentPolicy;
use crate::types::ShellEnvironmentPolicyInherit;
use std::collections::HashMap;
use std::collections::HashSet;
pub const CODEX_THREAD_ID_ENV_VAR: &str = "CODEX_THREAD_ID";
/// Construct a shell environment from the supplied process environment and
/// shell-environment policy.
pub fn create_env(
policy: &ShellEnvironmentPolicy,
thread_id: Option<&str>,
) -> HashMap<String, String> {
create_env_from_vars(std::env::vars(), policy, thread_id)
}
pub fn create_env_from_vars<I>(
vars: I,
policy: &ShellEnvironmentPolicy,
thread_id: Option<&str>,
) -> HashMap<String, String>
where
I: IntoIterator<Item = (String, String)>,
{
let mut env_map = populate_env(vars, policy, thread_id);
if cfg!(target_os = "windows") {
// This is a workaround to address the failures we are seeing in the
// following tests when run via Bazel on Windows:
//
// ```
// suite::shell_command::unicode_output::with_login
// suite::shell_command::unicode_output::without_login
// ```
//
// Currently, we can only reproduce these failures in CI, which makes
// iteration times long, so we include this quick fix for now to unblock
// getting the Windows Bazel build running.
if !env_map.keys().any(|k| k.eq_ignore_ascii_case("PATHEXT")) {
env_map.insert("PATHEXT".to_string(), ".COM;.EXE;.BAT;.CMD".to_string());
}
}
env_map
}
pub fn populate_env<I>(
vars: I,
policy: &ShellEnvironmentPolicy,
thread_id: Option<&str>,
) -> HashMap<String, String>
where
I: IntoIterator<Item = (String, String)>,
{
// Step 1 - determine the starting set of variables based on the
// `inherit` strategy.
let mut env_map: HashMap<String, String> = match policy.inherit {
ShellEnvironmentPolicyInherit::All => vars.into_iter().collect(),
ShellEnvironmentPolicyInherit::None => HashMap::new(),
ShellEnvironmentPolicyInherit::Core => {
let core_vars: HashSet<&str> = COMMON_CORE_VARS
.iter()
.copied()
.chain(PLATFORM_CORE_VARS.iter().copied())
.collect();
let is_core_var = |name: &str| {
if cfg!(target_os = "windows") {
core_vars
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(name))
} else {
core_vars.contains(name)
}
};
vars.into_iter().filter(|(k, _)| is_core_var(k)).collect()
}
};
// Internal helper - does `name` match any pattern in `patterns`?
let matches_any = |name: &str, patterns: &[EnvironmentVariablePattern]| -> bool {
patterns.iter().any(|pattern| pattern.matches(name))
};
// Step 2 - Apply the default exclude if not disabled.
if !policy.ignore_default_excludes {
let default_excludes = vec![
EnvironmentVariablePattern::new_case_insensitive("*KEY*"),
EnvironmentVariablePattern::new_case_insensitive("*SECRET*"),
EnvironmentVariablePattern::new_case_insensitive("*TOKEN*"),
];
env_map.retain(|k, _| !matches_any(k, &default_excludes));
}
// Step 3 - Apply custom excludes.
if !policy.exclude.is_empty() {
env_map.retain(|k, _| !matches_any(k, &policy.exclude));
}
// Step 4 - Apply user-provided overrides.
for (key, val) in &policy.r#set {
env_map.insert(key.clone(), val.clone());
}
// Step 5 - If include_only is non-empty, keep only the matching vars.
if !policy.include_only.is_empty() {
env_map.retain(|k, _| matches_any(k, &policy.include_only));
}
// Step 6 - Populate the thread ID environment variable when provided.
if let Some(thread_id) = thread_id {
env_map.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string());
}
env_map
}
const COMMON_CORE_VARS: &[&str] = &["PATH", "SHELL", "TMPDIR", "TEMP", "TMP"];
#[cfg(target_os = "windows")]
const PLATFORM_CORE_VARS: &[&str] = &["PATHEXT", "USERNAME", "USERPROFILE"];
#[cfg(unix)]
const PLATFORM_CORE_VARS: &[&str] = &["HOME", "LANG", "LC_ALL", "LC_CTYPE", "LOGNAME", "USER"];
+1 -1
View File
@@ -658,7 +658,7 @@ impl From<SandboxWorkspaceWrite> for codex_app_server_protocol::SandboxSettings
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum ShellEnvironmentPolicyInherit {
/// "Core" environment variables for the platform. On UNIX, this would
+5 -1
View File
@@ -42,7 +42,11 @@ switches to a no-`--argv0` compatibility path for the inner re-exec. If
`bwrap` is missing, it falls back to the vendored bubblewrap path compiled into
the binary and Codex surfaces a startup warning through its normal notification
path instead of printing directly from the sandbox helper. Codex also surfaces
a startup warning when bubblewrap cannot create user namespaces.
a startup warning when bubblewrap cannot create user namespaces. WSL2 uses the
normal Linux bubblewrap path. WSL1 is not supported for bubblewrap sandboxing
because it cannot create the required user namespaces, so Codex rejects
sandboxed shell commands that would enter the bubblewrap path before invoking
`bwrap`.
### Windows
+60 -3
View File
@@ -62,6 +62,7 @@ use codex_app_server_protocol::McpServerElicitationRequestParams;
use codex_config::types::OAuthCredentialsStoreMode;
use codex_exec_server::Environment;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::FileSystemSandboxContext;
use codex_features::FEATURES;
use codex_features::Feature;
use codex_features::unstable_features_warning_event;
@@ -1041,6 +1042,22 @@ impl TurnContext {
.map_or_else(|| self.cwd.clone(), |path| self.cwd.join(path))
}
pub(crate) fn file_system_sandbox_context(
&self,
additional_permissions: Option<PermissionProfile>,
) -> FileSystemSandboxContext {
FileSystemSandboxContext {
sandbox_policy: self.sandbox_policy.get().clone(),
windows_sandbox_level: self.windows_sandbox_level,
windows_sandbox_private_desktop: self
.config
.permissions
.windows_sandbox_private_desktop,
use_legacy_landlock: self.features.use_legacy_landlock(),
additional_permissions,
}
}
pub(crate) fn compact_prompt(&self) -> &str {
self.compact_prompt
.as_deref()
@@ -2266,7 +2283,7 @@ impl Session {
}
pub(crate) async fn route_realtime_text_input(self: &Arc<Self>, text: String) {
handlers::user_input_or_turn(
handlers::user_input_or_turn_inner(
self,
self.next_internal_sub_id(),
Op::UserInput {
@@ -2277,6 +2294,7 @@ impl Session {
final_output_json_schema: None,
responsesapi_client_metadata: None,
},
/*mirror_user_text_to_realtime*/ None,
)
.await;
}
@@ -4907,6 +4925,7 @@ mod handlers {
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::items::UserMessageItem;
use codex_protocol::mcp::RequestId as ProtocolRequestId;
use codex_protocol::user_input::UserInput;
use codex_rmcp_client::ElicitationAction;
@@ -4914,6 +4933,7 @@ mod handlers {
use serde_json::Value;
use std::path::PathBuf;
use std::sync::Arc;
use tracing::debug;
use tracing::info;
use tracing::warn;
@@ -4955,6 +4975,21 @@ mod handlers {
}
pub async fn user_input_or_turn(sess: &Arc<Session>, sub_id: String, op: Op) {
user_input_or_turn_inner(
sess,
sub_id,
op,
/*mirror_user_text_to_realtime*/ Some(()),
)
.await;
}
pub(super) async fn user_input_or_turn_inner(
sess: &Arc<Session>,
sub_id: String,
op: Op,
mirror_user_text_to_realtime: Option<()>,
) {
let (items, updates, responsesapi_client_metadata) = match op {
Op::UserTurn {
cwd,
@@ -5020,7 +5055,7 @@ mod handlers {
};
sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref())
.await;
match sess
let accepted_items = match sess
.steer_input(
items.clone(),
/*expected_turn_id*/ None,
@@ -5028,7 +5063,10 @@ mod handlers {
)
.await
{
Ok(_) => current_context.session_telemetry.user_prompt(&items),
Ok(_) => {
current_context.session_telemetry.user_prompt(&items);
Some(items)
}
Err(SteerInputError::NoActiveTurn(items)) => {
if let Some(responsesapi_client_metadata) = responsesapi_client_metadata {
current_context
@@ -5038,12 +5076,14 @@ mod handlers {
current_context.session_telemetry.user_prompt(&items);
sess.refresh_mcp_servers_if_requested(&current_context)
.await;
let accepted_items = items.clone();
sess.spawn_task(
Arc::clone(&current_context),
items,
crate::tasks::RegularTask::new(),
)
.await;
Some(accepted_items)
}
Err(err) => {
sess.send_event_raw(Event {
@@ -5051,7 +5091,24 @@ mod handlers {
msg: EventMsg::Error(err.to_error_event()),
})
.await;
None
}
};
if let (Some(items), Some(())) = (accepted_items, mirror_user_text_to_realtime) {
self::mirror_user_text_to_realtime(sess, &items).await;
}
}
async fn mirror_user_text_to_realtime(sess: &Arc<Session>, items: &[UserInput]) {
let text = UserMessageItem::new(items).message();
if text.is_empty() {
return;
}
if sess.conversation.running_state().await.is_none() {
return;
}
if let Err(err) = sess.conversation.text_in(text).await {
debug!("failed to mirror user text to realtime conversation: {err}");
}
}
+81 -9
View File
@@ -1525,7 +1525,7 @@ fn config_defaults_to_file_cli_auth_store_mode() -> std::io::Result<()> {
}
#[test]
fn config_honors_explicit_keyring_auth_store_mode() -> std::io::Result<()> {
fn config_resolves_explicit_keyring_auth_store_mode() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml {
cli_auth_credentials_store: Some(AuthCredentialsStoreMode::Keyring),
@@ -1540,14 +1540,17 @@ fn config_honors_explicit_keyring_auth_store_mode() -> std::io::Result<()> {
assert_eq!(
config.cli_auth_credentials_store_mode,
AuthCredentialsStoreMode::Keyring,
resolve_cli_auth_credentials_store_mode(
AuthCredentialsStoreMode::Keyring,
env!("CARGO_PKG_VERSION"),
),
);
Ok(())
}
#[test]
fn config_defaults_to_auto_oauth_store_mode() -> std::io::Result<()> {
fn config_resolves_default_oauth_store_mode() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
let cfg = ConfigToml::default();
@@ -1559,12 +1562,66 @@ fn config_defaults_to_auto_oauth_store_mode() -> std::io::Result<()> {
assert_eq!(
config.mcp_oauth_credentials_store_mode,
OAuthCredentialsStoreMode::Auto,
resolve_mcp_oauth_credentials_store_mode(
OAuthCredentialsStoreMode::Auto,
env!("CARGO_PKG_VERSION"),
),
);
Ok(())
}
#[test]
fn local_dev_builds_force_file_cli_auth_store_modes() {
assert_eq!(
resolve_cli_auth_credentials_store_mode(
AuthCredentialsStoreMode::Keyring,
LOCAL_DEV_BUILD_VERSION,
),
AuthCredentialsStoreMode::File,
);
assert_eq!(
resolve_cli_auth_credentials_store_mode(
AuthCredentialsStoreMode::Auto,
LOCAL_DEV_BUILD_VERSION,
),
AuthCredentialsStoreMode::File,
);
assert_eq!(
resolve_cli_auth_credentials_store_mode(
AuthCredentialsStoreMode::Ephemeral,
LOCAL_DEV_BUILD_VERSION,
),
AuthCredentialsStoreMode::Ephemeral,
);
assert_eq!(
resolve_cli_auth_credentials_store_mode(AuthCredentialsStoreMode::Keyring, "1.2.3"),
AuthCredentialsStoreMode::Keyring,
);
}
#[test]
fn local_dev_builds_force_file_mcp_oauth_store_modes() {
assert_eq!(
resolve_mcp_oauth_credentials_store_mode(
OAuthCredentialsStoreMode::Keyring,
LOCAL_DEV_BUILD_VERSION,
),
OAuthCredentialsStoreMode::File,
);
assert_eq!(
resolve_mcp_oauth_credentials_store_mode(
OAuthCredentialsStoreMode::Auto,
LOCAL_DEV_BUILD_VERSION,
),
OAuthCredentialsStoreMode::File,
);
assert_eq!(
resolve_mcp_oauth_credentials_store_mode(OAuthCredentialsStoreMode::Keyring, "1.2.3"),
OAuthCredentialsStoreMode::Keyring,
);
}
#[test]
fn feedback_enabled_defaults_to_true() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
@@ -1922,7 +1979,10 @@ async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> {
)?;
assert_eq!(
final_config.mcp_oauth_credentials_store_mode,
OAuthCredentialsStoreMode::Keyring,
resolve_mcp_oauth_credentials_store_mode(
OAuthCredentialsStoreMode::Keyring,
env!("CARGO_PKG_VERSION"),
),
);
Ok(())
@@ -4514,7 +4574,10 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
cwd: fixture.cwd(),
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: Constrained::allow_any(HashMap::new()),
mcp_oauth_credentials_store_mode: Default::default(),
mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode(
Default::default(),
LOCAL_DEV_BUILD_VERSION,
),
mcp_oauth_callback_port: None,
mcp_oauth_callback_url: None,
model_providers: fixture.model_provider_map.clone(),
@@ -4660,7 +4723,10 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
cwd: fixture.cwd(),
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: Constrained::allow_any(HashMap::new()),
mcp_oauth_credentials_store_mode: Default::default(),
mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode(
Default::default(),
LOCAL_DEV_BUILD_VERSION,
),
mcp_oauth_callback_port: None,
mcp_oauth_callback_url: None,
model_providers: fixture.model_provider_map.clone(),
@@ -4804,7 +4870,10 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
cwd: fixture.cwd(),
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: Constrained::allow_any(HashMap::new()),
mcp_oauth_credentials_store_mode: Default::default(),
mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode(
Default::default(),
LOCAL_DEV_BUILD_VERSION,
),
mcp_oauth_callback_port: None,
mcp_oauth_callback_url: None,
model_providers: fixture.model_provider_map.clone(),
@@ -4934,7 +5003,10 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
cwd: fixture.cwd(),
cli_auth_credentials_store_mode: Default::default(),
mcp_servers: Constrained::allow_any(HashMap::new()),
mcp_oauth_credentials_store_mode: Default::default(),
mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode(
Default::default(),
LOCAL_DEV_BUILD_VERSION,
),
mcp_oauth_callback_port: None,
mcp_oauth_callback_url: None,
model_providers: fixture.model_provider_map.clone(),
+36 -4
View File
@@ -124,6 +124,7 @@ pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB
pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option<usize> = Some(6);
pub(crate) const DEFAULT_AGENT_MAX_DEPTH: i32 = 1;
pub(crate) const DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS: Option<u64> = None;
const LOCAL_DEV_BUILD_VERSION: &str = "0.0.0";
pub const CONFIG_TOML_FILE: &str = "config.toml";
@@ -141,6 +142,32 @@ fn resolve_sqlite_home_env(resolved_cwd: &Path) -> Option<PathBuf> {
}
}
fn resolve_cli_auth_credentials_store_mode(
configured: AuthCredentialsStoreMode,
package_version: &str,
) -> AuthCredentialsStoreMode {
match (package_version, configured) {
(
LOCAL_DEV_BUILD_VERSION,
AuthCredentialsStoreMode::Keyring | AuthCredentialsStoreMode::Auto,
) => AuthCredentialsStoreMode::File,
(_, mode) => mode,
}
}
fn resolve_mcp_oauth_credentials_store_mode(
configured: OAuthCredentialsStoreMode,
package_version: &str,
) -> OAuthCredentialsStoreMode {
match (package_version, configured) {
(
LOCAL_DEV_BUILD_VERSION,
OAuthCredentialsStoreMode::Keyring | OAuthCredentialsStoreMode::Auto,
) => OAuthCredentialsStoreMode::File,
(_, mode) => mode,
}
}
#[cfg(test)]
pub(crate) fn test_config() -> Config {
let codex_home = tempfile::tempdir().expect("create temp dir");
@@ -324,8 +351,7 @@ pub struct Config {
/// Ordered list of status line item identifiers for the TUI.
///
/// When unset, the TUI defaults to: `model-with-reasoning`, `context-remaining`, and
/// `current-dir`.
/// When unset, the TUI defaults to: `model-with-reasoning` and `current-dir`.
pub tui_status_line: Option<Vec<String>>,
/// Ordered list of terminal title item identifiers for the TUI.
@@ -2015,11 +2041,17 @@ impl Config {
include_environment_context,
// The config.toml omits "_mode" because it's a config file. However, "_mode"
// is important in code to differentiate the mode from the store implementation.
cli_auth_credentials_store_mode: cfg.cli_auth_credentials_store.unwrap_or_default(),
cli_auth_credentials_store_mode: resolve_cli_auth_credentials_store_mode(
cfg.cli_auth_credentials_store.unwrap_or_default(),
env!("CARGO_PKG_VERSION"),
),
mcp_servers,
// The config.toml omits "_mode" because it's a config file. However, "_mode"
// is important in code to differentiate the mode from the store implementation.
mcp_oauth_credentials_store_mode: cfg.mcp_oauth_credentials_store.unwrap_or_default(),
mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode(
cfg.mcp_oauth_credentials_store.unwrap_or_default(),
env!("CARGO_PKG_VERSION"),
),
mcp_oauth_callback_port: cfg.mcp_oauth_callback_port,
mcp_oauth_callback_url: cfg.mcp_oauth_callback_url.clone(),
model_providers,
+1
View File
@@ -349,6 +349,7 @@ pub(crate) async fn execute_exec_request(
command,
cwd,
env,
exec_server_env_config: _,
network,
expiration,
capture_policy,
+10 -91
View File
@@ -1,11 +1,10 @@
#[cfg(test)]
use codex_config::types::EnvironmentVariablePattern;
use codex_config::types::ShellEnvironmentPolicy;
use codex_config::types::ShellEnvironmentPolicyInherit;
use codex_protocol::ThreadId;
use std::collections::HashMap;
use std::collections::HashSet;
pub const CODEX_THREAD_ID_ENV_VAR: &str = "CODEX_THREAD_ID";
pub use codex_config::shell_environment::CODEX_THREAD_ID_ENV_VAR;
/// Construct an environment map based on the rules in the specified policy. The
/// resulting map can be passed directly to `Command::envs()` after calling
@@ -21,9 +20,11 @@ pub fn create_env(
policy: &ShellEnvironmentPolicy,
thread_id: Option<ThreadId>,
) -> HashMap<String, String> {
create_env_from_vars(std::env::vars(), policy, thread_id)
let thread_id = thread_id.map(|thread_id| thread_id.to_string());
codex_config::shell_environment::create_env(policy, thread_id.as_deref())
}
#[cfg(all(test, target_os = "windows"))]
fn create_env_from_vars<I>(
vars: I,
policy: &ShellEnvironmentPolicy,
@@ -32,35 +33,11 @@ fn create_env_from_vars<I>(
where
I: IntoIterator<Item = (String, String)>,
{
let mut env_map = populate_env(vars, policy, thread_id);
if cfg!(target_os = "windows") {
// This is a workaround to address the failures we are seeing in the
// following tests when run via Bazel on Windows:
//
// ```
// suite::shell_command::unicode_output::with_login
// suite::shell_command::unicode_output::without_login
// ```
//
// Currently, we can only reproduce these failures in CI, which makes
// iteration times long, so we include this quick fix for now to unblock
// getting the Windows Bazel build running.
if !env_map.keys().any(|k| k.eq_ignore_ascii_case("PATHEXT")) {
env_map.insert("PATHEXT".to_string(), ".COM;.EXE;.BAT;.CMD".to_string());
}
}
env_map
let thread_id = thread_id.map(|thread_id| thread_id.to_string());
codex_config::shell_environment::create_env_from_vars(vars, policy, thread_id.as_deref())
}
const COMMON_CORE_VARS: &[&str] = &["PATH", "SHELL", "TMPDIR", "TEMP", "TMP"];
#[cfg(target_os = "windows")]
const PLATFORM_CORE_VARS: &[&str] = &["PATHEXT", "USERNAME", "USERPROFILE"];
#[cfg(unix)]
const PLATFORM_CORE_VARS: &[&str] = &["HOME", "LANG", "LC_ALL", "LC_CTYPE", "LOGNAME", "USER"];
#[cfg(test)]
fn populate_env<I>(
vars: I,
policy: &ShellEnvironmentPolicy,
@@ -69,66 +46,8 @@ fn populate_env<I>(
where
I: IntoIterator<Item = (String, String)>,
{
// Step 1 – determine the starting set of variables based on the
// `inherit` strategy.
let mut env_map: HashMap<String, String> = match policy.inherit {
ShellEnvironmentPolicyInherit::All => vars.into_iter().collect(),
ShellEnvironmentPolicyInherit::None => HashMap::new(),
ShellEnvironmentPolicyInherit::Core => {
let core_vars: HashSet<&str> = COMMON_CORE_VARS
.iter()
.copied()
.chain(PLATFORM_CORE_VARS.iter().copied())
.collect();
let is_core_var = |name: &str| {
if cfg!(target_os = "windows") {
core_vars
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(name))
} else {
core_vars.contains(name)
}
};
vars.into_iter().filter(|(k, _)| is_core_var(k)).collect()
}
};
// Internal helper – does `name` match **any** pattern in `patterns`?
let matches_any = |name: &str, patterns: &[EnvironmentVariablePattern]| -> bool {
patterns.iter().any(|pattern| pattern.matches(name))
};
// Step 2 – Apply the default exclude if not disabled.
if !policy.ignore_default_excludes {
let default_excludes = vec![
EnvironmentVariablePattern::new_case_insensitive("*KEY*"),
EnvironmentVariablePattern::new_case_insensitive("*SECRET*"),
EnvironmentVariablePattern::new_case_insensitive("*TOKEN*"),
];
env_map.retain(|k, _| !matches_any(k, &default_excludes));
}
// Step 3 – Apply custom excludes.
if !policy.exclude.is_empty() {
env_map.retain(|k, _| !matches_any(k, &policy.exclude));
}
// Step 4 – Apply user-provided overrides.
for (key, val) in &policy.r#set {
env_map.insert(key.clone(), val.clone());
}
// Step 5 – If include_only is non-empty, keep *only* the matching vars.
if !policy.include_only.is_empty() {
env_map.retain(|k, _| matches_any(k, &policy.include_only));
}
// Step 6 – Populate the thread ID environment variable when provided.
if let Some(thread_id) = thread_id {
env_map.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string());
}
env_map
let thread_id = thread_id.map(|thread_id| thread_id.to_string());
codex_config::shell_environment::populate_env(vars, policy, thread_id.as_deref())
}
#[cfg(test)]
+1
View File
@@ -26,6 +26,7 @@ pub(crate) use approval_request::GuardianApprovalRequest;
pub(crate) use approval_request::GuardianMcpAnnotations;
pub(crate) use approval_request::guardian_approval_request_to_json;
pub(crate) use review::guardian_rejection_message;
pub(crate) use review::guardian_timeout_message;
pub(crate) use review::is_guardian_reviewer_source;
pub(crate) use review::new_guardian_review_id;
pub(crate) use review::review_approval_request;
+42 -9
View File
@@ -38,6 +38,12 @@ const GUARDIAN_REJECTION_INSTRUCTIONS: &str = concat!(
"Otherwise, stop and request user input.",
);
const GUARDIAN_TIMEOUT_INSTRUCTIONS: &str = concat!(
"The automatic permission approval review did not finish before its deadline. ",
"Do not assume the action is unsafe based on the timeout alone. ",
"You may retry once, or ask the user for guidance or explicit approval.",
);
pub(crate) fn new_guardian_review_id() -> String {
uuid::Uuid::new_v4().to_string()
}
@@ -63,6 +69,10 @@ pub(crate) async fn guardian_rejection_message(session: &Session, review_id: &st
}
}
pub(crate) fn guardian_timeout_message() -> String {
GUARDIAN_TIMEOUT_INSTRUCTIONS.to_string()
}
#[derive(Debug)]
pub(super) enum GuardianReviewOutcome {
Completed(anyhow::Result<GuardianAssessment>),
@@ -97,8 +107,9 @@ pub(crate) fn is_guardian_reviewer_source(
)
}
/// This function always fails closed: any timeout, review-session failure, or
/// parse failure is treated as a high-risk denial.
/// This function always fails closed: timeouts, review-session failures, and
/// parse failures all block execution, but timeouts are still surfaced to the
/// caller as distinct from explicit guardian denials.
async fn run_guardian_review(
session: Arc<Session>,
turn: Arc<TurnContext>,
@@ -170,14 +181,36 @@ async fn run_guardian_review(
outcome: GuardianAssessmentOutcome::Deny,
rationale: format!("Automatic approval review failed: {err}"),
},
GuardianReviewOutcome::TimedOut => GuardianAssessment {
risk_level: GuardianRiskLevel::High,
user_authorization: GuardianUserAuthorization::Unknown,
outcome: GuardianAssessmentOutcome::Deny,
rationale:
GuardianReviewOutcome::TimedOut => {
let rationale =
"Automatic approval review timed out while evaluating the requested approval."
.to_string(),
},
.to_string();
session
.send_event(
turn.as_ref(),
EventMsg::Warning(WarningEvent {
message: rationale.clone(),
}),
)
.await;
session
.send_event(
turn.as_ref(),
EventMsg::GuardianAssessment(GuardianAssessmentEvent {
id: review_id,
target_item_id,
turn_id: assessment_turn_id,
status: GuardianAssessmentStatus::TimedOut,
risk_level: None,
user_authorization: None,
rationale: Some(rationale),
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
action: terminal_action,
}),
)
.await;
return ReviewDecision::TimedOut;
}
GuardianReviewOutcome::Aborted => {
session
.send_event(
+8
View File
@@ -718,6 +718,14 @@ async fn cancelled_guardian_review_emits_terminal_abort_without_warning() {
assert!(warnings.is_empty());
}
#[test]
fn guardian_timeout_message_distinguishes_timeout_from_policy_denial() {
let message = guardian_timeout_message();
assert!(message.contains("did not finish before its deadline"));
assert!(message.contains("retry once"));
assert!(!message.contains("unacceptable risk"));
}
#[tokio::test]
async fn routes_approval_to_guardian_requires_auto_only_review_policy() {
let (_session, mut turn) = crate::codex::make_session_and_context().await;
+5 -1
View File
@@ -24,6 +24,7 @@ use crate::guardian::GuardianApprovalRequest;
use crate::guardian::GuardianMcpAnnotations;
use crate::guardian::guardian_approval_request_to_json;
use crate::guardian::guardian_rejection_message;
use crate::guardian::guardian_timeout_message;
use crate::guardian::new_guardian_review_id;
use crate::guardian::review_approval_request;
use crate::guardian::routes_approval_to_guardian;
@@ -980,9 +981,12 @@ async fn mcp_tool_approval_decision_from_guardian(
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::NetworkPolicyAmendment { .. } => McpToolApprovalDecision::Accept,
ReviewDecision::ApprovedForSession => McpToolApprovalDecision::AcceptForSession,
ReviewDecision::Denied | ReviewDecision::TimedOut => McpToolApprovalDecision::Decline {
ReviewDecision::Denied => McpToolApprovalDecision::Decline {
message: Some(guardian_rejection_message(sess, review_id).await),
},
ReviewDecision::TimedOut => McpToolApprovalDecision::Decline {
message: Some(guardian_timeout_message()),
},
ReviewDecision::Abort => McpToolApprovalDecision::Decline { message: None },
}
}
+14
View File
@@ -843,6 +843,20 @@ async fn guardian_review_decision_maps_to_mcp_tool_decision() {
};
assert!(message.contains("Reason: too risky"));
assert!(message.contains("The agent must not attempt to achieve the same outcome"));
let timeout = mcp_tool_approval_decision_from_guardian(
session.as_ref(),
"review-id",
ReviewDecision::TimedOut,
)
.await;
let McpToolApprovalDecision::Decline {
message: Some(message),
} = timeout
else {
panic!("guardian timeout should carry a timeout message");
};
assert!(message.contains("did not finish before its deadline"));
assert!(!message.contains("unacceptable risk"));
assert_eq!(
mcp_tool_approval_decision_from_guardian(
session.as_ref(),
+4 -4
View File
@@ -169,14 +169,14 @@ async fn read_project_docs_with_fs(
break;
}
match fs.get_metadata(&p).await {
match fs.get_metadata(&p, /*sandbox*/ None).await {
Ok(metadata) if !metadata.is_file => continue,
Ok(_) => {}
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
Err(err) => return Err(err),
}
let mut data = match fs.read_file(&p).await {
let mut data = match fs.read_file(&p, /*sandbox*/ None).await {
Ok(data) => data,
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
Err(err) => return Err(err),
@@ -249,7 +249,7 @@ pub async fn discover_project_doc_paths(
for ancestor in dir.ancestors() {
for marker in &project_root_markers {
let marker_path = AbsolutePathBuf::try_from(ancestor.join(marker))?;
let marker_exists = match fs.get_metadata(&marker_path).await {
let marker_exists = match fs.get_metadata(&marker_path, /*sandbox*/ None).await {
Ok(_) => true,
Err(err) if err.kind() == io::ErrorKind::NotFound => false,
Err(err) => return Err(err),
@@ -289,7 +289,7 @@ pub async fn discover_project_doc_paths(
for d in search_dirs {
for name in &candidate_filenames {
let candidate = d.join(name);
match fs.get_metadata(&candidate).await {
match fs.get_metadata(&candidate, /*sandbox*/ None).await {
Ok(md) if md.is_file => {
found.push(candidate);
break;
+56 -26
View File
@@ -15,6 +15,7 @@ use std::collections::HashSet;
use std::ffi::OsStr;
use std::fs::DirEntry;
use std::io;
use std::mem::take;
use std::path::Path;
use std::path::PathBuf;
use tracing::debug;
@@ -26,7 +27,7 @@ const CURRENT_THREAD_SECTION_TOKEN_BUDGET: usize = 1_200;
const RECENT_WORK_SECTION_TOKEN_BUDGET: usize = 2_200;
const WORKSPACE_SECTION_TOKEN_BUDGET: usize = 1_600;
const NOTES_SECTION_TOKEN_BUDGET: usize = 300;
const MAX_CURRENT_THREAD_TURNS: usize = 2;
const CURRENT_THREAD_TURN_TOKEN_BUDGET: usize = 300;
const MAX_RECENT_THREADS: usize = 40;
const MAX_RECENT_WORK_GROUPS: usize = 8;
const MAX_CURRENT_CWD_ASKS: usize = 8;
@@ -204,10 +205,7 @@ fn build_current_thread_section(items: &[ResponseItem]) -> Option<String> {
continue;
};
if !current_user.is_empty() || !current_assistant.is_empty() {
turns.push((
std::mem::take(&mut current_user),
std::mem::take(&mut current_assistant),
));
turns.push((take(&mut current_user), take(&mut current_assistant)));
}
current_user.push(text);
}
@@ -231,43 +229,75 @@ fn build_current_thread_section(items: &[ResponseItem]) -> Option<String> {
turns.push((current_user, current_assistant));
}
let retained_turns = turns
.into_iter()
.rev()
.take(MAX_CURRENT_THREAD_TURNS)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>();
if retained_turns.is_empty() {
if turns.is_empty() {
return None;
}
let mut lines = vec![
"Most recent user/assistant turns from this exact thread. Use them for continuity when responding.".to_string(),
];
let mut remaining_budget =
CURRENT_THREAD_SECTION_TOKEN_BUDGET.saturating_sub(approx_token_count(&lines.join("\n")));
let mut retained_turn_count = 0;
let retained_turn_count = retained_turns.len();
for (index, (user_messages, assistant_messages)) in retained_turns.into_iter().enumerate() {
lines.push(String::new());
if retained_turn_count == 1 || index + 1 == retained_turn_count {
lines.push("### Latest turn".to_string());
for (index, (user_messages, assistant_messages)) in turns.into_iter().rev().enumerate() {
if remaining_budget == 0 {
break;
}
let mut turn_lines = Vec::new();
if index == 0 {
turn_lines.push("### Latest turn".to_string());
} else {
lines.push(format!("### Prior turn {}", index + 1));
turn_lines.push(format!("### Previous turn {index}"));
}
if !user_messages.is_empty() {
lines.push("User:".to_string());
lines.push(user_messages.join("\n\n"));
turn_lines.push("User:".to_string());
turn_lines.push(user_messages.join("\n\n"));
}
if !assistant_messages.is_empty() {
lines.push(String::new());
lines.push("Assistant:".to_string());
lines.push(assistant_messages.join("\n\n"));
turn_lines.push(String::new());
turn_lines.push("Assistant:".to_string());
turn_lines.push(assistant_messages.join("\n\n"));
}
let turn_budget = CURRENT_THREAD_TURN_TOKEN_BUDGET.min(remaining_budget);
let turn_text = turn_lines.join("\n");
let mut truncation_budget = turn_budget;
let turn_text = loop {
let candidate = truncate_text(&turn_text, TruncationPolicy::Tokens(truncation_budget));
let candidate_tokens = approx_token_count(&candidate);
if candidate_tokens <= turn_budget {
break candidate;
}
// The shared truncator adds its marker after choosing preserved
// content, so tighten the content budget until the rendered turn
// itself fits the per-turn cap.
let excess_tokens = candidate_tokens.saturating_sub(turn_budget);
let next_budget = truncation_budget.saturating_sub(excess_tokens.max(1));
if next_budget == 0 {
let candidate = truncate_text(&turn_text, TruncationPolicy::Tokens(0));
if approx_token_count(&candidate) <= turn_budget {
break candidate;
}
break String::new();
}
truncation_budget = next_budget;
};
let turn_tokens = approx_token_count(&turn_text);
if turn_tokens == 0 {
continue;
}
lines.push(String::new());
lines.push(turn_text);
remaining_budget = remaining_budget.saturating_sub(turn_tokens);
retained_turn_count += 1;
}
Some(lines.join("\n"))
(retained_turn_count > 0).then(|| lines.join("\n"))
}
fn build_workspace_section_with_user_root(
+117
View File
@@ -1,8 +1,11 @@
use super::build_current_thread_section;
use super::build_recent_work_section;
use super::build_workspace_section_with_user_root;
use chrono::TimeZone;
use chrono::Utc;
use codex_protocol::ThreadId;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_state::ThreadMetadata;
use pretty_assertions::assert_eq;
use std::fs;
@@ -43,6 +46,120 @@ fn thread_metadata(cwd: &str, title: &str, first_user_message: &str) -> ThreadMe
}
}
fn message(role: &str, content: ContentItem) -> ResponseItem {
ResponseItem::Message {
id: None,
role: role.to_string(),
content: vec![content],
end_turn: None,
phase: None,
}
}
fn user_message(text: impl Into<String>) -> ResponseItem {
message("user", ContentItem::InputText { text: text.into() })
}
fn assistant_message(text: impl Into<String>) -> ResponseItem {
message("assistant", ContentItem::OutputText { text: text.into() })
}
fn long_turn_text(index: usize) -> String {
format!(
"turn-{index}-start {} turn-{index}-middle {} turn-{index}-end",
"head filler ".repeat(160),
"tail filler ".repeat(240),
)
}
#[test]
fn current_thread_section_includes_short_turns_newest_first_until_budget() {
let items = vec![
user_message("user turn 1"),
assistant_message("assistant turn 1"),
user_message("user turn 2"),
assistant_message("assistant turn 2"),
user_message("user turn 3"),
assistant_message("assistant turn 3"),
user_message("user turn 4"),
assistant_message("assistant turn 4"),
];
assert_eq!(
build_current_thread_section(&items),
Some(
r#"Most recent user/assistant turns from this exact thread. Use them for continuity when responding.
### Latest turn
User:
user turn 4
Assistant:
assistant turn 4
### Previous turn 1
User:
user turn 3
Assistant:
assistant turn 3
### Previous turn 2
User:
user turn 2
Assistant:
assistant turn 2
### Previous turn 3
User:
user turn 1
Assistant:
assistant turn 1"#
.to_string()
)
);
}
#[test]
fn current_thread_turn_truncation_preserves_start_and_end() {
let items = vec![user_message(long_turn_text(/*index*/ 0))];
let section = build_current_thread_section(&items).expect("current thread section");
assert_eq!(
(
section.contains("turn-0-start"),
section.contains("turn-0-middle"),
section.contains("turn-0-end"),
section.contains("tokens truncated"),
),
(true, false, true, true),
);
}
#[test]
fn current_thread_section_keeps_latest_turns_when_history_exceeds_budget() {
let mut items = Vec::new();
for index in 1..=8 {
items.push(user_message(long_turn_text(index)));
items.push(assistant_message(format!("assistant turn {index}")));
}
let section = build_current_thread_section(&items).expect("current thread section");
assert_eq!(
(
section.contains("turn-8-start"),
section.contains("turn-8-end"),
section.contains("### Previous turn 2"),
section.contains("turn-1-start"),
section.contains("turn-1-end"),
),
(true, true, true, false, false),
);
}
#[test]
fn workspace_section_requires_meaningful_structure() {
let cwd = TempDir::new().expect("tempdir");
@@ -945,8 +945,6 @@ fn spawn_realtime_input_task(input: RealtimeInputTask) -> JoinHandle<()> {
user_text,
&writer,
&events_tx,
session_kind,
&mut response_create_queue,
)
.await
}
@@ -992,8 +990,6 @@ async fn handle_user_text_input(
text: Result<String, RecvError>,
writer: &RealtimeWebsocketWriter,
events_tx: &Sender<RealtimeEvent>,
session_kind: RealtimeSessionKind,
response_create_queue: &mut RealtimeResponseCreateQueue,
) -> anyhow::Result<()> {
let text = text.context("user text input channel closed")?;
@@ -1005,14 +1001,6 @@ async fn handle_user_text_input(
.await;
return Err(mapped_error.into());
}
match session_kind {
RealtimeSessionKind::V1 => {}
RealtimeSessionKind::V2 => {
response_create_queue
.request_create(writer, events_tx, "text")
.await?;
}
}
Ok(())
}
+9
View File
@@ -33,11 +33,18 @@ pub(crate) struct ExecOptions {
pub(crate) capture_policy: ExecCapturePolicy,
}
#[derive(Clone, Debug)]
pub(crate) struct ExecServerEnvConfig {
pub(crate) policy: codex_exec_server::ExecEnvPolicy,
pub(crate) local_policy_env: HashMap<String, String>,
}
#[derive(Debug)]
pub struct ExecRequest {
pub command: Vec<String>,
pub cwd: AbsolutePathBuf,
pub env: HashMap<String, String>,
pub(crate) exec_server_env_config: Option<ExecServerEnvConfig>,
pub network: Option<NetworkProxy>,
pub expiration: ExecExpiration,
pub capture_policy: ExecCapturePolicy,
@@ -72,6 +79,7 @@ impl ExecRequest {
command,
cwd,
env,
exec_server_env_config: None,
network,
expiration,
capture_policy,
@@ -121,6 +129,7 @@ impl ExecRequest {
command,
cwd,
env,
exec_server_env_config: None,
network,
expiration,
capture_policy,
+1
View File
@@ -162,6 +162,7 @@ pub(crate) async fn execute_user_shell_command(
command: exec_command.clone(),
cwd: cwd.clone(),
env: exec_env_map,
exec_server_env_config: None,
network: turn_context.network.clone(),
// TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we
// should use that instead of an "arbitrarily large" timeout here.
+57
View File
@@ -118,6 +118,63 @@ impl ToolOutput for CallToolResult {
}
}
#[derive(Clone, Debug)]
pub struct McpToolOutput {
pub result: CallToolResult,
pub wall_time: Duration,
}
impl ToolOutput for McpToolOutput {
fn log_preview(&self) -> String {
let payload = self.response_payload();
let preview = payload.body.to_text().unwrap_or_else(|| {
serde_json::to_string(&self.result.content)
.unwrap_or_else(|err| format!("failed to serialize mcp result: {err}"))
});
telemetry_preview(&preview)
}
fn success_for_logging(&self) -> bool {
self.result.success()
}
fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
ResponseInputItem::FunctionCallOutput {
call_id: call_id.to_string(),
output: self.response_payload(),
}
}
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
serde_json::to_value(&self.result).unwrap_or_else(|err| {
JsonValue::String(format!("failed to serialize mcp result: {err}"))
})
}
}
impl McpToolOutput {
fn response_payload(&self) -> FunctionCallOutputPayload {
let mut payload = self.result.as_function_call_output_payload();
let wall_time_seconds = self.wall_time.as_secs_f64();
let header = format!("Wall time: {wall_time_seconds:.4} seconds\nOutput:");
match &mut payload.body {
FunctionCallOutputBody::Text(text) => {
if text.is_empty() {
*text = header;
} else {
*text = format!("{header}\n{text}");
}
}
FunctionCallOutputBody::ContentItems(items) => {
items.insert(0, FunctionCallOutputContentItem::InputText { text: header });
}
}
payload
}
}
#[derive(Clone)]
pub struct ToolSearchOutput {
pub tools: Vec<ToolSearchOutputTool>,
+139
View File
@@ -85,6 +85,145 @@ fn mcp_code_mode_result_serializes_full_call_tool_result() {
);
}
#[test]
fn mcp_tool_output_response_item_includes_wall_time() {
let output = McpToolOutput {
result: CallToolResult {
content: vec![serde_json::json!({
"type": "text",
"text": "done",
})],
structured_content: None,
is_error: Some(false),
meta: None,
},
wall_time: std::time::Duration::from_millis(1250),
};
let response = output.to_response_item(
"mcp-call-1",
&ToolPayload::Mcp {
server: "server".to_string(),
tool: "tool".to_string(),
raw_arguments: "{}".to_string(),
},
);
match response {
ResponseInputItem::FunctionCallOutput { call_id, output } => {
assert_eq!(call_id, "mcp-call-1");
assert_eq!(output.success, Some(true));
let Some(text) = output.body.to_text() else {
panic!("MCP output should serialize as text");
};
let Some(payload) = text.strip_prefix("Wall time: 1.2500 seconds\nOutput:\n") else {
panic!("MCP output should include wall-time header: {text}");
};
let parsed: serde_json::Value = serde_json::from_str(payload).unwrap_or_else(|err| {
panic!("MCP output should serialize JSON content: {err}");
});
assert_eq!(
parsed,
json!([{
"type": "text",
"text": "done",
}])
);
}
other => panic!("expected FunctionCallOutput, got {other:?}"),
}
}
#[test]
fn mcp_tool_output_response_item_preserves_content_items() {
let image_url = "data:image/png;base64,AAA";
let output = McpToolOutput {
result: CallToolResult {
content: vec![serde_json::json!({
"type": "image",
"mimeType": "image/png",
"data": "AAA",
})],
structured_content: None,
is_error: Some(false),
meta: None,
},
wall_time: std::time::Duration::from_millis(500),
};
let response = output.to_response_item(
"mcp-call-2",
&ToolPayload::Mcp {
server: "server".to_string(),
tool: "tool".to_string(),
raw_arguments: "{}".to_string(),
},
);
match response {
ResponseInputItem::FunctionCallOutput { output, .. } => {
assert_eq!(
output.content_items(),
Some(
vec![
FunctionCallOutputContentItem::InputText {
text: "Wall time: 0.5000 seconds\nOutput:".to_string(),
},
FunctionCallOutputContentItem::InputImage {
image_url: image_url.to_string(),
detail: None,
},
]
.as_slice()
)
);
assert_eq!(
output.body.to_text().as_deref(),
Some("Wall time: 0.5000 seconds\nOutput:")
);
}
other => panic!("expected FunctionCallOutput, got {other:?}"),
}
}
#[test]
fn mcp_tool_output_code_mode_result_stays_raw_call_tool_result() {
let output = McpToolOutput {
result: CallToolResult {
content: vec![serde_json::json!({
"type": "text",
"text": "ignored",
})],
structured_content: Some(serde_json::json!({
"content": "done",
})),
is_error: Some(false),
meta: None,
},
wall_time: std::time::Duration::from_millis(1250),
};
let result = output.code_mode_result(&ToolPayload::Mcp {
server: "server".to_string(),
tool: "tool".to_string(),
raw_arguments: "{}".to_string(),
});
assert_eq!(
result,
serde_json::json!({
"content": [{
"type": "text",
"text": "ignored",
}],
"structuredContent": {
"content": "done",
},
"isError": false,
})
);
}
#[test]
fn custom_tool_calls_can_derive_text_from_content_items() {
let payload = ToolPayload::Custom {
@@ -176,7 +176,16 @@ impl ToolHandler for ApplyPatchHandler {
));
};
let fs = environment.get_filesystem();
match codex_apply_patch::maybe_parse_apply_patch_verified(&command, &cwd, fs.as_ref()).await
let sandbox = environment
.is_remote()
.then(|| turn.file_system_sandbox_context(/*additional_permissions*/ None));
match codex_apply_patch::maybe_parse_apply_patch_verified(
&command,
&cwd,
fs.as_ref(),
sandbox.as_ref(),
)
.await
{
codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => {
let (file_paths, effective_additional_permissions, file_system_sandbox_policy) =
@@ -273,7 +282,14 @@ pub(crate) async fn intercept_apply_patch(
call_id: &str,
tool_name: &str,
) -> Result<Option<FunctionToolOutput>, FunctionCallError> {
match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd, fs).await {
let sandbox = turn
.environment
.as_ref()
.filter(|env| env.is_remote())
.map(|_| turn.file_system_sandbox_context(/*additional_permissions*/ None));
match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd, fs, sandbox.as_ref())
.await
{
codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => {
session
.record_model_warning(
@@ -24,13 +24,17 @@ async fn approval_keys_include_move_destination() {
+new content
*** End Patch"#;
let argv = vec!["apply_patch".to_string(), patch.to_string()];
let action =
match codex_apply_patch::maybe_parse_apply_patch_verified(&argv, &cwd, LOCAL_FS.as_ref())
.await
{
MaybeApplyPatchVerified::Body(action) => action,
other => panic!("expected patch body, got: {other:?}"),
};
let action = match codex_apply_patch::maybe_parse_apply_patch_verified(
&argv,
&cwd,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await
{
MaybeApplyPatchVerified::Body(action) => action,
other => panic!("expected patch body, got: {other:?}"),
};
let keys = file_paths_for_action(&action);
assert_eq!(keys.len(), 2);
+9 -4
View File
@@ -1,16 +1,17 @@
use std::sync::Arc;
use std::time::Instant;
use crate::function_tool::FunctionCallError;
use crate::mcp_tool_call::handle_mcp_tool_call;
use crate::tools::context::McpToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_protocol::mcp::CallToolResult;
pub struct McpHandler;
impl ToolHandler for McpHandler {
type Output = CallToolResult;
type Output = McpToolOutput;
fn kind(&self) -> ToolKind {
ToolKind::Mcp
@@ -41,7 +42,8 @@ impl ToolHandler for McpHandler {
let (server, tool, raw_arguments) = payload;
let arguments_str = raw_arguments;
let output = handle_mcp_tool_call(
let started = Instant::now();
let result = handle_mcp_tool_call(
Arc::clone(&session),
&turn,
call_id.clone(),
@@ -51,6 +53,9 @@ impl ToolHandler for McpHandler {
)
.await;
Ok(output)
Ok(McpToolOutput {
result,
wall_time: started.elapsed(),
})
}
}
@@ -92,10 +92,13 @@ impl ToolHandler for ViewImageHandler {
"view_image is unavailable in this session".to_string(),
));
};
let sandbox = environment
.is_remote()
.then(|| turn.file_system_sandbox_context(/*additional_permissions*/ None));
let metadata = environment
.get_filesystem()
.get_metadata(&abs_path)
.get_metadata(&abs_path, sandbox.as_ref())
.await
.map_err(|error| {
FunctionCallError::RespondToModel(format!(
@@ -112,7 +115,7 @@ impl ToolHandler for ViewImageHandler {
}
let file_bytes = environment
.get_filesystem()
.read_file(&abs_path)
.read_file(&abs_path, sandbox.as_ref())
.await
.map_err(|error| {
FunctionCallError::RespondToModel(format!(
+12 -1
View File
@@ -1,6 +1,7 @@
use crate::codex::Session;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::guardian_rejection_message;
use crate::guardian::guardian_timeout_message;
use crate::guardian::new_guardian_review_id;
use crate::guardian::review_approval_request;
use crate::guardian::routes_approval_to_guardian;
@@ -488,7 +489,7 @@ impl NetworkApprovalService {
PendingApprovalDecision::Deny
}
},
ReviewDecision::Denied | ReviewDecision::TimedOut | ReviewDecision::Abort => {
ReviewDecision::Denied | ReviewDecision::Abort => {
if let Some(review_id) = guardian_review_id.as_deref() {
if let Some(owner_call) = owner_call.as_ref() {
let message = guardian_rejection_message(session.as_ref(), review_id).await;
@@ -507,6 +508,16 @@ impl NetworkApprovalService {
}
PendingApprovalDecision::Deny
}
ReviewDecision::TimedOut => {
if let Some(owner_call) = owner_call.as_ref() {
self.record_call_outcome(
&owner_call.registration_id,
NetworkApprovalOutcome::DeniedByPolicy(guardian_timeout_message()),
)
.await;
}
PendingApprovalDecision::Deny
}
};
if matches!(resolved, PendingApprovalDecision::AllowForSession) {
+9 -4
View File
@@ -7,6 +7,7 @@ retry with an escalated sandbox strategy on denial (no re‑approval thanks to
caching).
*/
use crate::guardian::guardian_rejection_message;
use crate::guardian::guardian_timeout_message;
use crate::guardian::new_guardian_review_id;
use crate::guardian::routes_approval_to_guardian;
use crate::network_policy_decision::network_approval_context_from_payload;
@@ -151,7 +152,7 @@ impl ToolOrchestrator {
otel.tool_decision(otel_tn, otel_ci, &decision, otel_source);
match decision {
ReviewDecision::Denied | ReviewDecision::TimedOut | ReviewDecision::Abort => {
ReviewDecision::Denied | ReviewDecision::Abort => {
let reason = if let Some(review_id) = guardian_review_id.as_deref() {
guardian_rejection_message(tool_ctx.session.as_ref(), review_id).await
} else {
@@ -159,6 +160,9 @@ impl ToolOrchestrator {
};
return Err(ToolError::Rejected(reason));
}
ReviewDecision::TimedOut => {
return Err(ToolError::Rejected(guardian_timeout_message()));
}
ReviewDecision::Approved
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::ApprovedForSession => {}
@@ -306,9 +310,7 @@ impl ToolOrchestrator {
otel.tool_decision(otel_tn, otel_ci, &decision, otel_source);
match decision {
ReviewDecision::Denied
| ReviewDecision::TimedOut
| ReviewDecision::Abort => {
ReviewDecision::Denied | ReviewDecision::Abort => {
let reason = if let Some(review_id) = guardian_review_id.as_deref() {
guardian_rejection_message(tool_ctx.session.as_ref(), review_id)
.await
@@ -317,6 +319,9 @@ impl ToolOrchestrator {
};
return Err(ToolError::Rejected(reason));
}
ReviewDecision::TimedOut => {
return Err(ToolError::Rejected(guardian_timeout_message()));
}
ReviewDecision::Approved
| ReviewDecision::ApprovedExecpolicyAmendment { .. }
| ReviewDecision::ApprovedForSession => {}
@@ -218,6 +218,9 @@ impl ToolRuntime<ApplyPatchRequest, ExecToolCallOutput> for ApplyPatchRuntime {
if let Some(environment) = ctx.turn.environment.as_ref().filter(|env| env.is_remote()) {
let started_at = Instant::now();
let fs = environment.get_filesystem();
let sandbox = ctx
.turn
.file_system_sandbox_context(req.additional_permissions.clone());
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let result = codex_apply_patch::apply_patch(
@@ -226,6 +229,7 @@ impl ToolRuntime<ApplyPatchRequest, ExecToolCallOutput> for ApplyPatchRuntime {
&mut stdout,
&mut stderr,
fs.as_ref(),
Some(&sandbox),
)
.await;
let stdout = String::from_utf8_lossy(&stdout).into_owned();
@@ -4,6 +4,7 @@ use crate::exec::ExecExpiration;
use crate::exec::is_likely_sandbox_denied;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::guardian_rejection_message;
use crate::guardian::guardian_timeout_message;
use crate::guardian::new_guardian_review_id;
use crate::guardian::review_approval_request;
use crate::guardian::routes_approval_to_guardian;
@@ -125,6 +126,7 @@ pub(super) async fn try_run_zsh_fork(
command,
cwd: sandbox_cwd,
env: sandbox_env,
exec_server_env_config: _,
network: sandbox_network,
expiration: _sandbox_expiration,
capture_policy: _capture_policy,
@@ -485,7 +487,7 @@ impl CoreShellActionProvider {
EscalationDecision::deny(Some("User denied execution".to_string()))
}
},
ReviewDecision::Denied | ReviewDecision::TimedOut => {
ReviewDecision::Denied => {
let message = if let Some(review_id) =
prompt_decision.guardian_review_id.as_deref()
{
@@ -495,6 +497,9 @@ impl CoreShellActionProvider {
};
EscalationDecision::deny(Some(message))
}
ReviewDecision::TimedOut => {
EscalationDecision::deny(Some(guardian_timeout_message()))
}
ReviewDecision::Abort => {
EscalationDecision::deny(Some("User cancelled execution".to_string()))
}
@@ -730,6 +735,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
command: self.command.clone(),
cwd: self.cwd.clone(),
env: exec_env,
exec_server_env_config: None,
network: self.network.clone(),
expiration: ExecExpiration::Cancellation(cancel_rx),
capture_policy: ExecCapturePolicy::ShellTool,
@@ -10,6 +10,7 @@ use crate::exec::ExecExpiration;
use crate::guardian::GuardianApprovalRequest;
use crate::guardian::review_approval_request;
use crate::sandboxing::ExecOptions;
use crate::sandboxing::ExecServerEnvConfig;
use crate::sandboxing::SandboxPermissions;
use crate::shell::ShellType;
use crate::tools::network_approval::NetworkApprovalMode;
@@ -52,6 +53,7 @@ pub struct UnifiedExecRequest {
pub process_id: i32,
pub cwd: AbsolutePathBuf,
pub env: HashMap<String, String>,
pub exec_server_env_config: Option<ExecServerEnvConfig>,
pub explicit_env_overrides: HashMap<String, String>,
pub network: Option<NetworkProxy>,
pub tty: bool,
@@ -237,9 +239,10 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
expiration: ExecExpiration::DefaultTimeout,
capture_policy: ExecCapturePolicy::ShellTool,
};
let exec_env = attempt
let mut exec_env = attempt
.env_for(command, options, req.network.as_ref())
.map_err(|err| ToolError::Codex(err.into()))?;
exec_env.exec_server_env_config = req.exec_server_env_config.clone();
match zsh_fork_backend::maybe_prepare_unified_exec(
req,
attempt,
@@ -294,9 +297,10 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
expiration: ExecExpiration::DefaultTimeout,
capture_policy: ExecCapturePolicy::ShellTool,
};
let exec_env = attempt
let mut exec_env = attempt
.env_for(command, options, req.network.as_ref())
.map_err(|err| ToolError::Codex(err.into()))?;
exec_env.exec_server_env_config = req.exec_server_env_config.clone();
let Some(environment) = ctx.turn.environment.as_ref() else {
return Err(ToolError::Rejected(
"exec_command is unavailable in this session".to_string(),
+9
View File
@@ -267,6 +267,15 @@ pub(crate) fn build_specs_with_discoverable_tools(
}
}
}
if let Some(deferred_mcp_tools) = deferred_mcp_tools.as_ref() {
for (name, _) in deferred_mcp_tools.iter().filter(|(name, _)| {
!mcp_tools
.as_ref()
.is_some_and(|tools| tools.contains_key(*name))
}) {
builder.register_handler(name.clone(), mcp_handler.clone());
}
}
builder
}
+2
View File
@@ -928,6 +928,8 @@ fn search_tool_registers_namespaced_mcp_tool_aliases() {
assert!(registry.has_handler(&ToolName::plain(TOOL_SEARCH_TOOL_NAME)));
assert!(registry.has_handler(&app_alias));
assert!(registry.has_handler(&mcp_alias));
assert!(registry.has_handler(&ToolName::plain("mcp__codex_apps__calendar_create_event")));
assert!(registry.has_handler(&ToolName::plain("mcp__rmcp__echo")));
}
#[test]
+11 -10
View File
@@ -66,10 +66,11 @@ pub(crate) struct OutputHandles {
/// Transport-specific process handle used by unified exec.
enum ProcessHandle {
Local(Box<ExecCommandSession>),
Remote(Arc<dyn ExecProcess>),
ExecServer(Arc<dyn ExecProcess>),
}
/// Unified wrapper over local PTY sessions and exec-server-backed processes.
/// Unified wrapper over directly spawned PTY sessions and exec-server-backed
/// processes.
pub(crate) struct UnifiedExecProcess {
process_handle: ProcessHandle,
output_tx: broadcast::Sender<Vec<u8>>,
@@ -135,7 +136,7 @@ impl UnifiedExecProcess {
.send(data.to_vec())
.await
.map_err(|_| UnifiedExecError::WriteToStdin),
ProcessHandle::Remote(process_handle) => {
ProcessHandle::ExecServer(process_handle) => {
match process_handle.write(data.to_vec()).await {
Ok(response) => match response.status {
WriteStatus::Accepted => Ok(()),
@@ -179,7 +180,7 @@ impl UnifiedExecProcess {
let state = self.state_rx.borrow().clone();
match &self.process_handle {
ProcessHandle::Local(process_handle) => state.has_exited || process_handle.has_exited(),
ProcessHandle::Remote(_) => state.has_exited,
ProcessHandle::ExecServer(_) => state.has_exited,
}
}
@@ -189,7 +190,7 @@ impl UnifiedExecProcess {
ProcessHandle::Local(process_handle) => {
state.exit_code.or_else(|| process_handle.exit_code())
}
ProcessHandle::Remote(_) => state.exit_code,
ProcessHandle::ExecServer(_) => state.exit_code,
}
}
@@ -198,7 +199,7 @@ impl UnifiedExecProcess {
self.output_closed_notify.notify_waiters();
match &self.process_handle {
ProcessHandle::Local(process_handle) => process_handle.terminate(),
ProcessHandle::Remote(process_handle) => {
ProcessHandle::ExecServer(process_handle) => {
let process_handle = Arc::clone(process_handle);
tokio::spawn(async move {
let _ = process_handle.terminate().await;
@@ -331,14 +332,14 @@ impl UnifiedExecProcess {
Ok(managed)
}
pub(super) async fn from_remote_started(
pub(super) async fn from_exec_server_started(
started: StartedExecProcess,
sandbox_type: SandboxType,
) -> Result<Self, UnifiedExecError> {
let process_handle = ProcessHandle::Remote(Arc::clone(&started.process));
let process_handle = ProcessHandle::ExecServer(Arc::clone(&started.process));
let mut managed = Self::new(process_handle, sandbox_type, /*spawn_lifecycle*/ None);
let output_handles = managed.output_handles();
managed.output_task = Some(Self::spawn_remote_output_task(
managed.output_task = Some(Self::spawn_exec_server_output_task(
started,
output_handles,
managed.output_tx.clone(),
@@ -366,7 +367,7 @@ impl UnifiedExecProcess {
Ok(managed)
}
fn spawn_remote_output_task(
fn spawn_exec_server_output_task(
started: StartedExecProcess,
output_handles: OutputHandles,
output_tx: broadcast::Sender<Vec<u8>>,
@@ -11,9 +11,11 @@ use tokio::time::Duration;
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
use crate::exec_env::CODEX_THREAD_ID_ENV_VAR;
use crate::exec_env::create_env;
use crate::exec_policy::ExecApprovalRequest;
use crate::sandboxing::ExecRequest;
use crate::sandboxing::ExecServerEnvConfig;
use crate::tools::context::ExecCommandToolOutput;
use crate::tools::events::ToolEmitter;
use crate::tools::events::ToolEventCtx;
@@ -47,6 +49,7 @@ use crate::unified_exec::process::OutputBuffer;
use crate::unified_exec::process::OutputHandles;
use crate::unified_exec::process::SpawnLifecycleHandle;
use crate::unified_exec::process::UnifiedExecProcess;
use codex_config::types::ShellEnvironmentPolicy;
use codex_protocol::protocol::ExecCommandSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_output_truncation::approx_token_count;
@@ -89,6 +92,70 @@ fn apply_unified_exec_env(mut env: HashMap<String, String>) -> HashMap<String, S
env
}
fn exec_env_policy_from_shell_policy(
policy: &ShellEnvironmentPolicy,
) -> codex_exec_server::ExecEnvPolicy {
codex_exec_server::ExecEnvPolicy {
inherit: policy.inherit.clone(),
ignore_default_excludes: policy.ignore_default_excludes,
exclude: policy
.exclude
.iter()
.map(std::string::ToString::to_string)
.collect(),
r#set: policy.r#set.clone(),
include_only: policy
.include_only
.iter()
.map(std::string::ToString::to_string)
.collect(),
}
}
fn env_overlay_for_exec_server(
request_env: &HashMap<String, String>,
local_policy_env: &HashMap<String, String>,
) -> HashMap<String, String> {
request_env
.iter()
.filter(|(key, value)| local_policy_env.get(*key) != Some(*value))
.map(|(key, value)| (key.clone(), value.clone()))
.collect()
}
fn exec_server_env_for_request(
request: &ExecRequest,
) -> (
Option<codex_exec_server::ExecEnvPolicy>,
HashMap<String, String>,
) {
if let Some(exec_server_env_config) = &request.exec_server_env_config {
(
Some(exec_server_env_config.policy.clone()),
env_overlay_for_exec_server(&request.env, &exec_server_env_config.local_policy_env),
)
} else {
(None, request.env.clone())
}
}
fn exec_server_params_for_request(
process_id: i32,
request: &ExecRequest,
tty: bool,
) -> codex_exec_server::ExecParams {
let (env_policy, env) = exec_server_env_for_request(request);
codex_exec_server::ExecParams {
process_id: exec_server_process_id(process_id).into(),
argv: request.command.clone(),
cwd: request.cwd.to_path_buf(),
env_policy,
env,
tty,
arg0: request.arg0.clone(),
}
}
/// Borrowed process state prepared for a `write_stdin` or poll operation.
struct PreparedProcessHandles {
process: Arc<UnifiedExecProcess>,
@@ -587,12 +654,7 @@ impl UnifiedExecProcessManager {
mut spawn_lifecycle: SpawnLifecycleHandle,
environment: &codex_exec_server::Environment,
) -> Result<UnifiedExecProcess, UnifiedExecError> {
let (program, args) = request
.command
.split_first()
.ok_or(UnifiedExecError::MissingCommandLine)?;
let inherited_fds = spawn_lifecycle.inherited_fds();
if environment.is_remote() {
if !inherited_fds.is_empty() {
return Err(UnifiedExecError::create_process(
@@ -602,19 +664,17 @@ impl UnifiedExecProcessManager {
let started = environment
.get_exec_backend()
.start(codex_exec_server::ExecParams {
process_id: exec_server_process_id(process_id).into(),
argv: request.command.clone(),
cwd: request.cwd.to_path_buf(),
env: request.env.clone(),
tty,
arg0: request.arg0.clone(),
})
.start(exec_server_params_for_request(process_id, request, tty))
.await
.map_err(|err| UnifiedExecError::create_process(err.to_string()))?;
return UnifiedExecProcess::from_remote_started(started, request.sandbox).await;
spawn_lifecycle.after_spawn();
return UnifiedExecProcess::from_exec_server_started(started, request.sandbox).await;
}
let (program, args) = request
.command
.split_first()
.ok_or(UnifiedExecError::MissingCommandLine)?;
let spawn_result = if tty {
codex_utils_pty::pty::spawn_process_with_inherited_fds(
program,
@@ -649,10 +709,20 @@ impl UnifiedExecProcessManager {
cwd: AbsolutePathBuf,
context: &UnifiedExecContext,
) -> Result<(UnifiedExecProcess, Option<DeferredNetworkApproval>), UnifiedExecError> {
let env = apply_unified_exec_env(create_env(
let local_policy_env = create_env(
&context.turn.shell_environment_policy,
Some(context.session.conversation_id),
));
/*thread_id*/ None,
);
let mut env = local_policy_env.clone();
env.insert(
CODEX_THREAD_ID_ENV_VAR.to_string(),
context.session.conversation_id.to_string(),
);
let env = apply_unified_exec_env(env);
let exec_server_env_config = ExecServerEnvConfig {
policy: exec_env_policy_from_shell_policy(&context.turn.shell_environment_policy),
local_policy_env,
};
let mut orchestrator = ToolOrchestrator::new();
let mut runtime = UnifiedExecRuntime::new(
self,
@@ -680,6 +750,7 @@ impl UnifiedExecProcessManager {
process_id: request.process_id,
cwd,
env,
exec_server_env_config: Some(exec_server_env_config),
explicit_env_overrides: context.turn.shell_environment_policy.r#set.clone(),
network: request.network.clone(),
tty: request.tty,
@@ -34,6 +34,92 @@ fn unified_exec_env_overrides_existing_values() {
assert_eq!(env.get("PATH"), Some(&"/usr/bin".to_string()));
}
#[test]
fn env_overlay_for_exec_server_keeps_runtime_changes_only() {
let local_policy_env = HashMap::from([
("HOME".to_string(), "/client-home".to_string()),
("PATH".to_string(), "/client-path".to_string()),
("SHELL_SET".to_string(), "policy".to_string()),
]);
let request_env = HashMap::from([
("HOME".to_string(), "/client-home".to_string()),
("PATH".to_string(), "/sandbox-path".to_string()),
("SHELL_SET".to_string(), "policy".to_string()),
("CODEX_THREAD_ID".to_string(), "thread-1".to_string()),
(
"CODEX_SANDBOX_NETWORK_DISABLED".to_string(),
"1".to_string(),
),
]);
assert_eq!(
env_overlay_for_exec_server(&request_env, &local_policy_env),
HashMap::from([
("PATH".to_string(), "/sandbox-path".to_string()),
("CODEX_THREAD_ID".to_string(), "thread-1".to_string()),
(
"CODEX_SANDBOX_NETWORK_DISABLED".to_string(),
"1".to_string()
),
])
);
}
#[test]
fn exec_server_params_use_env_policy_overlay_contract() {
let request = ExecRequest {
command: vec!["bash".to_string(), "-lc".to_string(), "true".to_string()],
cwd: std::env::current_dir()
.expect("current dir")
.try_into()
.expect("absolute path"),
env: HashMap::from([
("HOME".to_string(), "/client-home".to_string()),
("PATH".to_string(), "/sandbox-path".to_string()),
("CODEX_THREAD_ID".to_string(), "thread-1".to_string()),
]),
exec_server_env_config: Some(ExecServerEnvConfig {
policy: codex_exec_server::ExecEnvPolicy {
inherit: codex_config::types::ShellEnvironmentPolicyInherit::Core,
ignore_default_excludes: false,
exclude: Vec::new(),
r#set: HashMap::new(),
include_only: Vec::new(),
},
local_policy_env: HashMap::from([
("HOME".to_string(), "/client-home".to_string()),
("PATH".to_string(), "/client-path".to_string()),
]),
}),
network: None,
expiration: crate::exec::ExecExpiration::DefaultTimeout,
capture_policy: crate::exec::ExecCapturePolicy::ShellTool,
sandbox: codex_sandboxing::SandboxType::None,
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
file_system_sandbox_policy: codex_protocol::permissions::FileSystemSandboxPolicy::from(
&codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
),
network_sandbox_policy: codex_protocol::permissions::NetworkSandboxPolicy::Restricted,
windows_sandbox_filesystem_overrides: None,
arg0: None,
};
let params =
exec_server_params_for_request(/*process_id*/ 123, &request, /*tty*/ true);
assert_eq!(params.process_id.as_str(), "123");
assert!(params.env_policy.is_some());
assert_eq!(
params.env,
HashMap::from([
("PATH".to_string(), "/sandbox-path".to_string()),
("CODEX_THREAD_ID".to_string(), "thread-1".to_string()),
])
);
}
#[test]
fn exec_server_process_id_matches_unified_exec_process_id() {
assert_eq!(exec_server_process_id(/*process_id*/ 4321), "4321");
@@ -76,7 +76,7 @@ async fn remote_process(write_status: WriteStatus) -> UnifiedExecProcess {
}),
};
UnifiedExecProcess::from_remote_started(started, SandboxType::None)
UnifiedExecProcess::from_exec_server_started(started, SandboxType::None)
.await
.expect("remote process should start")
}
@@ -133,7 +133,7 @@ async fn remote_process_waits_for_early_exit_event() {
let _ = wake_tx.send(1);
});
let process = UnifiedExecProcess::from_remote_started(started, SandboxType::None)
let process = UnifiedExecProcess::from_exec_server_started(started, SandboxType::None)
.await
.expect("remote process should observe early exit");
+22 -9
View File
@@ -148,7 +148,11 @@ pub async fn test_env() -> Result<TestEnv> {
let cwd = remote_aware_cwd_path();
environment
.get_filesystem()
.create_directory(&cwd, CreateDirectoryOptions { recursive: true })
.create_directory(
&cwd,
CreateDirectoryOptions { recursive: true },
/*sandbox*/ None,
)
.await?;
remote_process.process.register_cleanup_path(cwd.as_path());
Ok(TestEnv {
@@ -170,10 +174,9 @@ struct RemoteExecServerStart {
fn start_remote_exec_server(remote_env: &RemoteEnvConfig) -> Result<RemoteExecServerStart> {
let container_name = remote_env.container_name.as_str();
let instance_id = remote_exec_server_instance_id();
let remote_exec_server_path = format!("/tmp/codex-exec-server-{instance_id}");
let remote_exec_server_path = format!("/tmp/codex-{instance_id}");
let stdout_path = format!("/tmp/codex-exec-server-{instance_id}.stdout");
let local_binary = codex_utils_cargo_bin::cargo_bin("codex-exec-server")
.context("resolve codex-exec-server binary")?;
let local_binary = codex_utils_cargo_bin::cargo_bin("codex").context("resolve codex binary")?;
let local_binary = local_binary.to_string_lossy().to_string();
let remote_binary = format!("{container_name}:{remote_exec_server_path}");
@@ -188,7 +191,7 @@ fn start_remote_exec_server(remote_env: &RemoteEnvConfig) -> Result<RemoteExecSe
let start_script = format!(
"rm -f {stdout_path}; \
nohup {remote_exec_server_path} --listen ws://0.0.0.0:0 > {stdout_path} 2>&1 & \
nohup {remote_exec_server_path} exec-server --listen ws://0.0.0.0:0 > {stdout_path} 2>&1 & \
echo $!"
);
let pid_output =
@@ -836,18 +839,26 @@ impl TestCodexHarness {
if let Some(parent) = abs_path.parent() {
self.test
.fs()
.create_directory(&parent, CreateDirectoryOptions { recursive: true })
.create_directory(
&parent,
CreateDirectoryOptions { recursive: true },
/*sandbox*/ None,
)
.await?;
}
self.test
.fs()
.write_file(&abs_path, contents.as_ref().to_vec())
.write_file(&abs_path, contents.as_ref().to_vec(), /*sandbox*/ None)
.await?;
Ok(())
}
pub async fn read_file_text(&self, rel: impl AsRef<Path>) -> Result<String> {
Ok(self.test.fs().read_file_text(&self.path_abs(rel)).await?)
Ok(self
.test
.fs()
.read_file_text(&self.path_abs(rel), /*sandbox*/ None)
.await?)
}
pub async fn create_dir_all(&self, rel: impl AsRef<Path>) -> Result<()> {
@@ -856,6 +867,7 @@ impl TestCodexHarness {
.create_directory(
&self.path_abs(rel),
CreateDirectoryOptions { recursive: true },
/*sandbox*/ None,
)
.await?;
Ok(())
@@ -874,13 +886,14 @@ impl TestCodexHarness {
recursive: false,
force: true,
},
/*sandbox*/ None,
)
.await?;
Ok(())
}
pub async fn abs_path_exists(&self, path: &AbsolutePathBuf) -> Result<bool> {
match self.test.fs().get_metadata(path).await {
match self.test.fs().get_metadata(path, /*sandbox*/ None).await {
Ok(_) => Ok(true),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
Err(err) => Err(err.into()),
+28 -8
View File
@@ -33,9 +33,14 @@ async fn agents_override_is_preferred_over_agents_md() -> Result<()> {
agents_instructions(test_codex().with_workspace_setup(|cwd, fs| async move {
let agents_md = cwd.join("AGENTS.md");
let override_md = cwd.join("AGENTS.override.md");
fs.write_file(&agents_md, b"base doc".to_vec()).await?;
fs.write_file(&override_md, b"override doc".to_vec())
fs.write_file(&agents_md, b"base doc".to_vec(), /*sandbox*/ None)
.await?;
fs.write_file(
&override_md,
b"override doc".to_vec(),
/*sandbox*/ None,
)
.await?;
Ok::<(), anyhow::Error>(())
}))
.await?;
@@ -62,9 +67,14 @@ async fn configured_fallback_is_used_when_agents_candidate_is_directory() -> Res
.with_workspace_setup(|cwd, fs| async move {
let agents_dir = cwd.join("AGENTS.md");
let fallback = cwd.join("WORKFLOW.md");
fs.create_directory(&agents_dir, CreateDirectoryOptions { recursive: true })
fs.create_directory(
&agents_dir,
CreateDirectoryOptions { recursive: true },
/*sandbox*/ None,
)
.await?;
fs.write_file(&fallback, b"fallback doc".to_vec(), /*sandbox*/ None)
.await?;
fs.write_file(&fallback, b"fallback doc".to_vec()).await?;
Ok::<(), anyhow::Error>(())
}),
)
@@ -95,12 +105,22 @@ async fn agents_docs_are_concatenated_from_project_root_to_cwd() -> Result<()> {
let git_marker = root.join(".git");
let nested_agents = nested.join("AGENTS.md");
fs.create_directory(&nested, CreateDirectoryOptions { recursive: true })
fs.create_directory(
&nested,
CreateDirectoryOptions { recursive: true },
/*sandbox*/ None,
)
.await?;
fs.write_file(&root_agents, b"root doc".to_vec(), /*sandbox*/ None)
.await?;
fs.write_file(&root_agents, b"root doc".to_vec()).await?;
fs.write_file(&git_marker, b"gitdir: /tmp/mock-git-dir\n".to_vec())
fs.write_file(
&git_marker,
b"gitdir: /tmp/mock-git-dir\n".to_vec(),
/*sandbox*/ None,
)
.await?;
fs.write_file(&nested_agents, b"child doc".to_vec(), /*sandbox*/ None)
.await?;
fs.write_file(&nested_agents, b"child doc".to_vec()).await?;
Ok::<(), anyhow::Error>(())
}),
)
@@ -27,7 +27,8 @@ async fn hierarchical_agents_appends_to_project_doc_in_user_instructions() {
})
.with_workspace_setup(|cwd, fs| async move {
let agents_md = cwd.join("AGENTS.md");
fs.write_file(&agents_md, b"be nice".to_vec()).await?;
fs.write_file(&agents_md, b"be nice".to_vec(), /*sandbox*/ None)
.await?;
Ok::<(), anyhow::Error>(())
});
let test = builder
+6 -1
View File
@@ -30,9 +30,14 @@ pub static CODEX_ALIASES_TEMP_DIR: Option<TestCodexAliasesGuard> = {
.and_then(|name| name.to_str())
.unwrap_or("");
let argv1 = args.next().unwrap_or_default();
if argv1 == CODEX_CORE_APPLY_PATCH_ARG1 {
let _ = arg0_dispatch();
return None;
}
// Helper re-execs inherit this ctor too, but they may run inside a sandbox
// where creating another CODEX_HOME tempdir under /tmp is not allowed.
if exe_name == CODEX_LINUX_SANDBOX_ARG0 || argv1 == CODEX_CORE_APPLY_PATCH_ARG1 {
if exe_name == CODEX_LINUX_SANDBOX_ARG0 {
return None;
}
@@ -2,9 +2,12 @@ use anyhow::Context;
use anyhow::Result;
use chrono::Utc;
use codex_config::config_toml::RealtimeWsVersion;
use codex_core::test_support::auth_manager_from_auth;
use codex_login::CodexAuth;
use codex_login::OPENAI_API_KEY_ENV_VAR;
use codex_protocol::ThreadId;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ConversationAudioParams;
use codex_protocol::protocol::ConversationStartParams;
@@ -12,12 +15,14 @@ use codex_protocol::protocol::ConversationStartTransport;
use codex_protocol::protocol::ConversationTextParams;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RealtimeAudioFrame;
use codex_protocol::protocol::RealtimeConversationRealtimeEvent;
use codex_protocol::protocol::RealtimeConversationVersion;
use codex_protocol::protocol::RealtimeEvent;
use codex_protocol::protocol::RealtimeVoice;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionSource;
use codex_protocol::user_input::UserInput;
use core_test_support::responses;
@@ -1508,6 +1513,165 @@ async fn conversation_start_injects_startup_context_from_thread_history() -> Res
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_startup_context_current_thread_selects_many_turns_by_budget() -> Result<()> {
skip_if_no_network!(Ok(()));
let api_server = start_mock_server().await;
let realtime_server = start_websocket_server(vec![vec![vec![json!({
"type": "session.updated",
"session": { "id": "sess_current_thread_budget", "instructions": "backend prompt" }
})]]])
.await;
let latest_long_user_turn = format!(
"latest-long-start {} latest-long-middle {} latest-long-end",
"head detail ".repeat(120),
"tail detail ".repeat(170),
);
let mut user_turns = (1..=7)
.map(|index| {
format!(
"short-turn-{index}-start {} short-turn-{index}-end",
"detail ".repeat(86)
)
})
.collect::<Vec<_>>();
user_turns.push(latest_long_user_turn.clone());
let mut builder = test_codex().with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.realtime.version = RealtimeWsVersion::V1;
}
});
let test = builder.build(&api_server).await?;
// Seed completed turns through a resumed thread so this remains an
// end-to-end startup-context test without paying for a model turn per
// fixture entry in platform CI.
let history = user_turns
.into_iter()
.enumerate()
.flat_map(|(index, user_turn)| {
let turn_number = index + 1;
let assistant_turn = format!("assistant turn {turn_number}");
[
RolloutItem::ResponseItem(ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText { text: user_turn }],
end_turn: None,
phase: None,
}),
RolloutItem::ResponseItem(ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: assistant_turn,
}],
end_turn: None,
phase: None,
}),
]
})
.collect::<Vec<_>>();
test.codex.shutdown_and_wait().await?;
let resumed_thread = test
.thread_manager
.resume_thread_with_history(
test.config.clone(),
InitialHistory::Forked(history),
auth_manager_from_auth(CodexAuth::from_api_key("dummy")),
/*persist_extended_history*/ false,
/*parent_trace*/ None,
)
.await?;
let codex = resumed_thread.thread;
codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: Some(Some("backend prompt".to_string())),
session_id: None,
transport: None,
voice: None,
}))
.await?;
let startup_context_request = wait_for_matching_websocket_request(
&realtime_server,
"current thread budget startup context request with instructions",
|request| websocket_request_instructions(request).is_some(),
)
.await;
let startup_context = websocket_request_instructions(&startup_context_request)
.expect("startup context request should contain instructions");
// Isolate only the Current Thread section; the startup prompt may also include
// workspace and notes sections after it.
let current_thread_start = startup_context
.find("## Current Thread")
.expect("startup context should include current thread section");
let current_thread_and_rest = &startup_context[current_thread_start..];
let current_thread_end = [
"\n## Recent Work",
"\n## Machine / Workspace Map",
"\n## Notes",
]
.iter()
.filter_map(|marker| current_thread_and_rest.find(marker))
.min()
.unwrap_or(current_thread_and_rest.len());
let current_thread = &current_thread_and_rest[..current_thread_end];
let rendered_turns = current_thread
.split("\n### ")
.skip(1)
.map(|turn| format!("### {turn}"))
.collect::<Vec<_>>();
let over_budget_turns = rendered_turns
.iter()
.filter_map(|turn| {
let token_count = turn.len().div_ceil(4);
(token_count > 300).then(|| {
(
turn.lines().next().unwrap_or_default().to_string(),
token_count,
)
})
})
.collect::<Vec<_>>();
let latest_rendered_source =
format!("### Latest turn\nUser:\n{latest_long_user_turn}\n\nAssistant:\nassistant turn 8");
// Snapshot the actual section so turn order, oldest-first omission, and
// start/end truncation behavior are reviewed together.
let snapshot = format!(
"latest_source_tokens: {}\nrendered_turn_count: {}\nover_budget_turns: {over_budget_turns:?}\n\n{current_thread}",
latest_rendered_source.len().div_ceil(4),
rendered_turns.len(),
);
insta::assert_snapshot!(
"conversation_startup_context_current_thread_selects_many_turns_by_budget",
snapshot
);
// The input includes a turn over 300 approximate tokens, and every rendered
// turn still fits the per-turn cap after labels and truncation markers.
assert_eq!(
(
latest_rendered_source.len().div_ceil(4) > 300,
over_budget_turns,
),
(true, Vec::<(String, usize)>::new()),
);
codex.shutdown_and_wait().await?;
realtime_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_startup_context_falls_back_to_workspace_map() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1626,6 +1790,126 @@ async fn conversation_startup_context_is_truncated_and_sent_once_per_start() ->
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_user_text_turn_is_sent_to_realtime_when_active() -> Result<()> {
skip_if_no_network!(Ok(()));
let api_server = start_mock_server().await;
let response_mock = responses::mount_sse_once(
&api_server,
responses::sse(vec![
responses::ev_response_created("resp_user_text"),
responses::ev_assistant_message("msg_user_text", "ack"),
responses::ev_completed("resp_user_text"),
]),
)
.await;
let realtime_server = start_websocket_server(vec![vec![
vec![json!({
"type": "session.updated",
"session": { "id": "sess_user_text", "instructions": "backend prompt" }
})],
vec![],
]])
.await;
let mut builder = test_codex().with_config({
let realtime_base_url = realtime_server.uri().to_string();
move |config| {
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
config.experimental_realtime_ws_startup_context = Some(String::new());
}
});
let test = builder.build(&api_server).await?;
test.codex
.submit(Op::RealtimeConversationStart(ConversationStartParams {
prompt: Some(Some("backend prompt".to_string())),
session_id: None,
transport: None,
voice: None,
}))
.await?;
let session_updated = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
payload: RealtimeEvent::SessionUpdated { session_id, .. },
}) => Some(session_id.clone()),
_ => None,
})
.await;
assert_eq!(session_updated, "sess_user_text");
let user_text = "typed follow-up for realtime";
test.codex
.submit(Op::UserInput {
items: vec![UserInput::Text {
text: user_text.to_string(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
})
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let realtime_text_request = wait_for_matching_websocket_request(
&realtime_server,
"normal user turn text mirrored to realtime",
|request| websocket_request_text(request).as_deref() == Some(user_text),
)
.await;
let model_user_texts = response_mock.single_request().message_input_texts("user");
assert_eq!(
(
model_user_texts.iter().any(|text| text == user_text),
websocket_request_text(&realtime_text_request),
),
(true, Some(user_text.to_string())),
);
let realtime_response_create = timeout(Duration::from_millis(200), async {
wait_for_matching_websocket_request(
&realtime_server,
"unexpected realtime response request for mirrored user text",
|request| request.body_json()["type"].as_str() == Some("response.create"),
)
.await
})
.await;
assert!(
realtime_response_create.is_err(),
"mirrored user text should not request a realtime response"
);
let realtime_request_body = realtime_text_request.body_json();
let content = &realtime_request_body["item"]["content"][0];
let snapshot = format!(
"type: {}\nitem.type: {}\nitem.role: {}\ncontent[0].type: {}\ncontent[0].text: {}\nresponse.create: {}",
realtime_request_body["type"].as_str().unwrap_or_default(),
realtime_request_body["item"]["type"]
.as_str()
.unwrap_or_default(),
realtime_request_body["item"]["role"]
.as_str()
.unwrap_or_default(),
content["type"].as_str().unwrap_or_default(),
content["text"].as_str().unwrap_or_default(),
realtime_response_create.is_ok(),
);
insta::assert_snapshot!(
"conversation_user_text_turn_is_sent_to_realtime_when_active",
snapshot
);
realtime_server.shutdown().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn conversation_mirrors_assistant_message_text_to_realtime_handoff() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -2603,6 +2887,7 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> {
"type": "session.updated",
"session": { "id": "sess_steer", "instructions": "backend prompt" }
})],
vec![],
vec![
json!({
"type": "conversation.input_transcript.delta",
@@ -2658,6 +2943,12 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> {
matches!(event, EventMsg::AgentMessageContentDelta(_))
})
.await;
let _ = wait_for_matching_websocket_request(
&realtime_server,
"first prompt mirrored to realtime",
|request| websocket_request_text(request).as_deref() == Some("first prompt"),
)
.await;
test.codex
.submit(Op::RealtimeConversationAudio(ConversationAudioParams {
+5 -2
View File
@@ -21,9 +21,11 @@ async fn remote_test_env_can_connect_and_use_filesystem() -> Result<()> {
let payload = b"remote-test-env-ok".to_vec();
file_system
.write_file(&file_path_abs, payload.clone())
.write_file(&file_path_abs, payload.clone(), /*sandbox*/ None)
.await?;
let actual = file_system
.read_file(&file_path_abs, /*sandbox*/ None)
.await?;
let actual = file_system.read_file(&file_path_abs).await?;
assert_eq!(actual, payload);
file_system
@@ -33,6 +35,7 @@ async fn remote_test_env_can_connect_and_use_filesystem() -> Result<()> {
recursive: false,
force: true,
},
/*sandbox*/ None,
)
.await?;
+52 -9
View File
@@ -30,6 +30,7 @@ use codex_protocol::protocol::Op;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::user_input::UserInput;
use codex_utils_cargo_bin::cargo_bin;
use core_test_support::assert_regex_match;
use core_test_support::responses;
use core_test_support::responses::mount_models_once;
use core_test_support::responses::mount_sse_once;
@@ -51,6 +52,29 @@ use tokio::time::sleep;
static OPENAI_PNG: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAD0AAAA9CAYAAAAeYmHpAAAE6klEQVR4Aeyau44UVxCGx1fZsmRLlm3Zoe0XcGQ5cUiCCIgJeS9CHgAhMkISQnIuGQgJEkBcxLW+nqnZ6uqqc+nuWRC7q/P3qetf9e+MtOwyX25O4Nep6JPyop++0qev9HrfgZ+F6r2DuB/vHOrt/UIkqdDHYvujOW6fO7h/CNEI+a5jc+pBR8uy0jVFsziYu5HtfSUk+Io34q921hLNctFSX0gwww+S8wce8K1LfCU+cYW4888aov8NxqvQILUPPReLOrm6zyLxa4i+6VZuFbJo8d1MOHZm+7VUtB/aIvhPWc/3SWg49JcwFLlHxuXKjtyloo+YNhuW3VS+WPBuUEMvCFKjEDVgFBQHXrnazpqiSxNZCkQ1kYiozsbm9Oz7l4i2Il7vGccGNWAc3XosDrZe/9P3ZnMmzHNEQw4smf8RQ87XEAMsC7Az0Au+dgXerfH4+sHvEc0SYGic8WBBUGqFH2gN7yDrazy7m2pbRTeRmU3+MjZmr1h6LJgPbGy23SI6GlYT0brQ71IY8Us4PNQCm+zepSbaD2BY9xCaAsD9IIj/IzFmKMSdHHonwdZATbTnYREf6/VZGER98N9yCWIvXQwXDoDdhZJoT8jwLnJXDB9w4Sb3e6nK5ndzlkTLnP3JBu4LKkbrYrU69gCVceV0JvpyuW1xlsUVngzhwMetn/XamtTORF9IO5YnWNiyeF9zCAfqR3fUW+vZZKLtgP+ts8BmQRBREAdRDhH3o8QuRh/YucNFz2BEjxbRN6LGzphfKmvP6v6QhqIQyZ8XNJ0W0X83MR1PEcJBNO2KC2Z1TW/v244scp9FwRViZxIOBF0Lctk7ZVSavdLvRlV1hz/ysUi9sr8CIcB3nvWBwA93ykTz18eAYxQ6N/K2DkPA1lv3iXCwmDUT7YkjIby9siXueIJj9H+pzSqJ9oIuJWTUgSSt4WO7o/9GGg0viR4VinNRUDoIj34xoCd6pxD3aK3zfdbnx5v1J3ZNNEJsE0sBG7N27ReDrJc4sFxz7dI/ZAbOmmiKvHBitQXpAdR6+F7v+/ol/tOouUV01EeMZQF2BoQDn6dP4XNr+j9GZEtEK1/L8pFw7bd3a53tsTa7WD+054jOFmPg1XBKPQgnqFfmFcy32ZRvjmiIIQTYFvyDxQ8nH8WIwwGwlyDjDznnilYyFr6njrlZwsKkBpO59A7OwgdzPEWRm+G+oeb7IfyNuzjEEVLrOVxJsxvxwF8kmCM6I2QYmJunz4u4TrADpfl7mlbRTWQ7VmrBzh3+C9f6Grc3YoGN9dg/SXFthpRsT6vobfXRs2VBlgBHXVMLHjDNbIZv1sZ9+X3hB09cXdH1JKViyG0+W9bWZDa/r2f9zAFR71sTzGpMSWz2iI4YssWjWo3REy1MDGjdwe5e0dFSiAC1JakBvu4/CUS8Eh6dqHdU0Or0ioY3W5ClSqDXAy7/6SRfgw8vt4I+tbvvNtFT2kVDhY5+IGb1rCqYaXNF08vSALsXCPmt0kQNqJT1p5eI1mkIV/BxCY1z85lOzeFbPBQHURkkPTlwTYK9gTVE25l84IbFFN+YJDHjdpn0gq6mrHht0dkcjbM4UL9283O5p77GN+SPW/QwVB4IUYg7Or+Kp7naR6qktP98LNF2UxWo9yObPIT9KYg+hK4i56no4rfnM0qeyFf6AwAAAP//trwR3wAAAAZJREFUAwBZ0sR75itw5gAAAABJRU5ErkJggg==";
fn assert_wall_time_line(line: &str) {
assert_regex_match(r"^Wall time: [0-9]+(?:\.[0-9]+)? seconds$", line);
}
fn split_wall_time_wrapped_output(output: &str) -> &str {
let Some((wall_time, rest)) = output.split_once('\n') else {
panic!("wall-time output should contain an Output section: {output}");
};
assert_wall_time_line(wall_time);
let Some(output) = rest.strip_prefix("Output:\n") else {
panic!("wall-time output should contain Output marker: {output}");
};
output
}
fn assert_wall_time_header(output: &str) {
let Some((wall_time, marker)) = output.split_once('\n') else {
panic!("wall-time header should contain an Output marker: {output}");
};
assert_wall_time_line(wall_time);
assert_eq!(marker, "Output:");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
#[serial(mcp_test_value)]
async fn stdio_server_round_trip() -> anyhow::Result<()> {
@@ -71,7 +95,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
]),
)
.await;
mount_sse_once(
let final_mock = mount_sse_once(
&server,
responses::sse(vec![
responses::ev_assistant_message("msg-1", "rmcp echo tool completed successfully."),
@@ -190,6 +214,17 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let output_item = final_mock.single_request().function_call_output(call_id);
let output_text = output_item
.get("output")
.and_then(Value::as_str)
.expect("function_call_output output should be a string");
let wrapped_payload = split_wall_time_wrapped_output(output_text);
let output_json: Value = serde_json::from_str(wrapped_payload)
.expect("wrapped MCP output should preserve structured JSON");
assert_eq!(output_json["echo"], "ECHOING: ping");
assert_eq!(output_json["env"], expected_env_value);
server.verify().await;
Ok(())
@@ -362,15 +397,22 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let output_item = final_mock.single_request().function_call_output(call_id);
assert_eq!(output_item["type"], "function_call_output");
assert_eq!(output_item["call_id"], call_id);
let output = output_item["output"]
.as_array()
.expect("image MCP output should be content items");
assert_eq!(output.len(), 2);
assert_wall_time_header(
output[0]["text"]
.as_str()
.expect("first MCP image output item should be wall-time text"),
);
assert_eq!(
output_item,
output[1],
json!({
"type": "function_call_output",
"call_id": call_id,
"output": [{
"type": "input_image",
"image_url": OPENAI_PNG
}]
"type": "input_image",
"image_url": OPENAI_PNG
})
);
server.verify().await;
@@ -533,7 +575,8 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
.get("output")
.and_then(Value::as_str)
.expect("function_call_output output should be a JSON string");
let output_json: Value = serde_json::from_str(output_text)
let wrapped_payload = split_wall_time_wrapped_output(output_text);
let output_json: Value = serde_json::from_str(wrapped_payload)
.expect("function_call_output output should be valid JSON");
assert_eq!(
output_json,
@@ -0,0 +1,52 @@
---
source: core/tests/suite/realtime_conversation.rs
expression: snapshot
---
latest_source_tokens: 897
rendered_turn_count: 6
over_budget_turns: []
## Current Thread
Most recent user/assistant turns from this exact thread. Use them for continuity when responding.
### Latest turn
User:
latest-long-start head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head detail head d…604 tokens truncated… tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail tail detail latest-long-end
Assistant:
assistant turn 8
### Previous turn 1
User:
short-turn-7-start detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail short-turn-7-end
Assistant:
assistant turn 7
### Previous turn 2
User:
short-turn-6-start detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail short-turn-6-end
Assistant:
assistant turn 6
### Previous turn 3
User:
short-turn-5-start detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail short-turn-5-end
Assistant:
assistant turn 5
### Previous turn 4
User:
short-turn-4-start detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail short-turn-4-end
Assistant:
assistant turn 4
### Previous turn 5
User:
short-turn-3-start detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail detail short-turn-3-end
Assistant:
assistant turn 3
@@ -0,0 +1,10 @@
---
source: core/tests/suite/realtime_conversation.rs
expression: snapshot
---
type: conversation.item.create
item.type: message
item.role: user
content[0].type: input_text
content[0].text: typed follow-up for realtime
response.create: false
+20 -18
View File
@@ -28,6 +28,14 @@ use serde_json::json;
use std::collections::HashMap;
use std::time::Duration;
fn assert_wall_time_header(output: &str) {
let (wall_time, marker) = output
.split_once('\n')
.expect("wall-time header should contain an Output marker");
assert_regex_match(r"^Wall time: [0-9]+(?:\.[0-9]+)? seconds$", wall_time);
assert_eq!(marker, "Output:");
}
// Verifies that a standard tool call (shell_command) exceeding the model formatting
// limits is truncated before being sent back to the model.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -400,9 +408,9 @@ async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()>
"MCP output should not include line-based truncation header: {output}"
);
let truncated_pattern = r#"(?s)^\{"echo":\s*"ECHOING: long-message-with-newlines-.*tokens truncated.*long-message-with-newlines-.*$"#;
let truncated_pattern = r#"(?s)^Wall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n\{"echo":\s*"ECHOING: long-message-with-newlines-.*tokens truncated.*long-message-with-newlines-.*$"#;
assert_regex_match(truncated_pattern, &output);
assert!(output.len() < 2500, "{}", output.len());
assert!(output.len() < 2600, "{}", output.len());
Ok(())
}
@@ -502,13 +510,18 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> {
// Wait for completion to ensure the outbound request is captured.
wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let output_item = final_mock.single_request().function_call_output(call_id);
// Expect exactly one array element: the image item; and no trailing summary text.
// Expect exactly the wall-time text and image item; no trailing truncation summary.
let output = output_item.get("output").expect("output");
assert!(output.is_array(), "expected array output");
let arr = output.as_array().unwrap();
assert_eq!(arr.len(), 1, "no truncation summary should be appended");
assert_eq!(arr.len(), 2, "no truncation summary should be appended");
assert_wall_time_header(
arr[0]["text"]
.as_str()
.expect("first MCP image output item should be wall-time text"),
);
assert_eq!(
arr[0],
arr[1],
json!({"type": "input_image", "image_url": openai_png})
);
@@ -758,22 +771,11 @@ async fn mcp_tool_call_output_not_truncated_with_custom_limit() -> Result<()> {
.function_call_output_text(call_id)
.context("function_call_output present for rmcp call")?;
let parsed: Value = serde_json::from_str(&output)?;
assert_eq!(
output.len(),
80031,
"parsed MCP output should retain its serialized length"
80065,
"MCP output should retain its serialized length plus wall-time header"
);
let expected_echo = format!("ECHOING: {large_msg}");
let echo_str = parsed["echo"]
.as_str()
.context("echo field should be a string in rmcp echo output")?;
assert_eq!(
echo_str.len(),
expected_echo.len(),
"echo length should match"
);
assert_eq!(echo_str, expected_echo);
assert!(
!output.contains("truncated"),
"output should not include truncation markers when limit is raised: {output}"
+5 -1
View File
@@ -191,7 +191,11 @@ async fn create_workspace_directory(
) -> Result<std::path::PathBuf> {
let abs_path = test.config.cwd.join(rel_path.as_ref());
test.fs()
.create_directory(&abs_path, CreateDirectoryOptions { recursive: true })
.create_directory(
&abs_path,
CreateDirectoryOptions { recursive: true },
/*sandbox*/ None,
)
.await?;
Ok(abs_path.into_path_buf())
}
+13 -3
View File
@@ -87,7 +87,11 @@ fn png_bytes(width: u32, height: u32, rgba: [u8; 4]) -> anyhow::Result<Vec<u8>>
async fn create_workspace_directory(test: &TestCodex, rel_path: &str) -> anyhow::Result<PathBuf> {
let abs_path = test.config.cwd.join(rel_path);
test.fs()
.create_directory(&abs_path, CreateDirectoryOptions { recursive: true })
.create_directory(
&abs_path,
CreateDirectoryOptions { recursive: true },
/*sandbox*/ None,
)
.await?;
Ok(abs_path.into_path_buf())
}
@@ -100,10 +104,16 @@ async fn write_workspace_file(
let abs_path = test.config.cwd.join(rel_path);
if let Some(parent) = abs_path.parent() {
test.fs()
.create_directory(&parent, CreateDirectoryOptions { recursive: true })
.create_directory(
&parent,
CreateDirectoryOptions { recursive: true },
/*sandbox*/ None,
)
.await?;
}
test.fs().write_file(&abs_path, contents).await?;
test.fs()
.write_file(&abs_path, contents, /*sandbox*/ None)
.await?;
Ok(abs_path.into_path_buf())
}
+4
View File
@@ -3,5 +3,9 @@ load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "exec-server",
crate_name = "codex_exec_server",
extra_binaries = [
"//codex-rs/cli:codex",
"//codex-rs/linux-sandbox:codex-linux-sandbox",
],
test_tags = ["no-sandbox"],
)
+3 -5
View File
@@ -7,10 +7,6 @@ license.workspace = true
[lib]
doctest = false
[[bin]]
name = "codex-exec-server"
path = "src/bin/codex-exec-server.rs"
[lints]
workspace = true
@@ -18,9 +14,10 @@ workspace = true
arc-swap = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-app-server-protocol = { workspace = true }
codex-config = { workspace = true }
codex-protocol = { workspace = true }
codex-sandboxing = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-pty = { workspace = true }
futures = { workspace = true }
@@ -46,5 +43,6 @@ uuid = { workspace = true, features = ["v4"] }
anyhow = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
pretty_assertions = { workspace = true }
serial_test = { workspace = true }
tempfile = { workspace = true }
test-case = "3.3.1"
+109 -41
View File
@@ -1,27 +1,25 @@
# codex-exec-server
`codex-exec-server` is a small standalone JSON-RPC server for spawning
and controlling subprocesses through `codex-utils-pty`.
`codex-exec-server` is the library backing `codex exec-server`, a small
JSON-RPC server for spawning and controlling subprocesses through
`codex-utils-pty`.
This PR intentionally lands only the standalone binary, client, wire protocol,
and docs. Exec and filesystem methods are stubbed server-side here and are
implemented in follow-up PRs.
It provides:
It currently provides:
- a standalone binary: `codex-exec-server`
- a CLI entrypoint: `codex exec-server`
- a Rust client: `ExecServerClient`
- a small protocol module with shared request/response types
This crate is intentionally narrow. It is not wired into the main Codex CLI or
unified-exec in this PR; it is only the standalone transport layer.
This crate owns the transport, protocol, and filesystem/process handlers. The
top-level `codex` binary owns hidden helper dispatch for sandboxed
filesystem operations and `codex-linux-sandbox`.
## Transport
The server speaks the shared `codex-app-server-protocol` message envelope on
the wire.
The standalone binary supports:
The CLI entrypoint supports:
- `ws://IP:PORT` (default)
@@ -36,7 +34,7 @@ Each connection follows this sequence:
1. Send `initialize`.
2. Wait for the `initialize` response.
3. Send `initialized`.
4. Call exec or filesystem RPCs once the follow-up implementation PRs land.
4. Call process or filesystem RPCs.
If the server receives any notification other than `initialized`, it replies
with an error using request id `-1`.
@@ -72,7 +70,7 @@ Handshake acknowledgement notification sent by the client after a successful
Params are currently ignored. Sending any other notification method is treated
as an invalid request.
### `command/exec`
### `process/start`
Starts a new managed process.
@@ -87,7 +85,6 @@ Request params:
"PATH": "/usr/bin:/bin"
},
"tty": true,
"outputBytesCap": 16384,
"arg0": null
}
```
@@ -100,31 +97,61 @@ Field definitions:
- `env`: environment variables passed to the child process.
- `tty`: when `true`, spawn a PTY-backed interactive process; when `false`,
spawn a pipe-backed process with closed stdin.
- `outputBytesCap`: maximum retained stdout/stderr bytes per stream for the
in-memory buffer. Defaults to `codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP`.
- `arg0`: optional argv0 override forwarded to `codex-utils-pty`.
Response:
```json
{
"processId": "proc-1",
"running": true,
"exitCode": null,
"stdout": null,
"stderr": null
"processId": "proc-1"
}
```
Behavior notes:
- Reusing an existing `processId` is rejected.
- PTY-backed processes accept later writes through `command/exec/write`.
- PTY-backed processes accept later writes through `process/write`.
- Pipe-backed processes are launched with stdin closed and reject writes.
- Output is streamed asynchronously via `command/exec/outputDelta`.
- Exit is reported asynchronously via `command/exec/exited`.
- Output is streamed asynchronously via `process/output`.
- Exit is reported asynchronously via `process/exited`.
### `command/exec/write`
### `process/read`
Reads buffered output and terminal state for a managed process.
Request params:
```json
{
"processId": "proc-1",
"afterSeq": null,
"maxBytes": 65536,
"waitMs": 1000
}
```
Field definitions:
- `processId`: managed process id returned by `process/start`.
- `afterSeq`: optional sequence number cursor; when present, only newer chunks
are returned.
- `maxBytes`: optional response byte budget.
- `waitMs`: optional long-poll timeout in milliseconds.
Response:
```json
{
"chunks": [],
"nextSeq": 1,
"exited": false,
"exitCode": null,
"closed": false,
"failure": null
}
```
### `process/write`
Writes raw bytes to a running PTY-backed process stdin.
@@ -143,7 +170,7 @@ Response:
```json
{
"accepted": true
"status": "accepted"
}
```
@@ -152,7 +179,7 @@ Behavior notes:
- Writes to an unknown `processId` are rejected.
- Writes to a non-PTY process are rejected because stdin is already closed.
### `command/exec/terminate`
### `process/terminate`
Terminates a running managed process.
@@ -182,7 +209,7 @@ If the process is already unknown or already removed, the server responds with:
## Notifications
### `command/exec/outputDelta`
### `process/output`
Streaming output chunk from a running process.
@@ -191,6 +218,7 @@ Params:
```json
{
"processId": "proc-1",
"seq": 1,
"stream": "stdout",
"chunk": "aGVsbG8K"
}
@@ -199,10 +227,11 @@ Params:
Fields:
- `processId`: process identifier
- `stream`: `"stdout"` or `"stderr"`
- `seq`: per-process output sequence number
- `stream`: `"stdout"`, `"stderr"`, or `"pty"`
- `chunk`: base64-encoded output bytes
### `command/exec/exited`
### `process/exited`
Final process exit notification.
@@ -211,10 +240,43 @@ Params:
```json
{
"processId": "proc-1",
"seq": 2,
"exitCode": 0
}
```
### `process/closed`
Notification emitted after process output is closed and the process handle is
removed.
Params:
```json
{
"processId": "proc-1"
}
```
## Filesystem RPCs
Filesystem methods use absolute paths and return JSON-RPC errors for invalid
or unavailable paths:
- `fs/readFile`
- `fs/writeFile`
- `fs/createDirectory`
- `fs/getMetadata`
- `fs/readDirectory`
- `fs/remove`
- `fs/copy`
Each filesystem request accepts an optional `sandbox` object. When `sandbox`
contains a `ReadOnly` or `WorkspaceWrite` policy, the operation runs in a
hidden helper process launched from the top-level `codex` executable and
prepared through the shared sandbox transform path. Helper requests and
responses are passed over stdin/stdout.
## Errors
The server returns JSON-RPC errors with these codes:
@@ -231,6 +293,7 @@ Typical error cases:
- duplicate `processId`
- writes to unknown processes
- writes to non-PTY processes
- sandbox-denied filesystem operations
## Rust surface
@@ -240,10 +303,14 @@ The crate exports:
- `ExecServerError`
- `ExecServerClientConnectOptions`
- `RemoteExecServerConnectArgs`
- protocol structs `InitializeParams` and `InitializeResponse`
- protocol request/response structs for process and filesystem RPCs
- `DEFAULT_LISTEN_URL` and `ExecServerListenUrlParseError`
- `run_main_with_listen_url()`
- `run_main()` for embedding the websocket server in a binary
- `ExecServerRuntimePaths`
- `run_main()` for embedding the websocket server
Callers must pass `ExecServerRuntimePaths` to `run_main()`. The top-level
`codex exec-server` command builds these paths from the `codex` arg0 dispatch
state.
## Example session
@@ -258,23 +325,24 @@ Initialize:
Start a process:
```json
{"id":2,"method":"command/exec","params":{"processId":"proc-1","argv":["bash","-lc","printf 'ready\\n'; while IFS= read -r line; do printf 'echo:%s\\n' \"$line\"; done"],"cwd":"/tmp","env":{"PATH":"/usr/bin:/bin"},"tty":true,"outputBytesCap":4096,"arg0":null}}
{"id":2,"result":{"processId":"proc-1","running":true,"exitCode":null,"stdout":null,"stderr":null}}
{"method":"command/exec/outputDelta","params":{"processId":"proc-1","stream":"stdout","chunk":"cmVhZHkK"}}
{"id":2,"method":"process/start","params":{"processId":"proc-1","argv":["bash","-lc","printf 'ready\\n'; while IFS= read -r line; do printf 'echo:%s\\n' \"$line\"; done"],"cwd":"/tmp","env":{"PATH":"/usr/bin:/bin"},"tty":true,"arg0":null}}
{"id":2,"result":{"processId":"proc-1"}}
{"method":"process/output","params":{"processId":"proc-1","seq":1,"stream":"stdout","chunk":"cmVhZHkK"}}
```
Write to the process:
```json
{"id":3,"method":"command/exec/write","params":{"processId":"proc-1","chunk":"aGVsbG8K"}}
{"id":3,"result":{"accepted":true}}
{"method":"command/exec/outputDelta","params":{"processId":"proc-1","stream":"stdout","chunk":"ZWNobzpoZWxsbwo="}}
{"id":3,"method":"process/write","params":{"processId":"proc-1","chunk":"aGVsbG8K"}}
{"id":3,"result":{"status":"accepted"}}
{"method":"process/output","params":{"processId":"proc-1","seq":2,"stream":"stdout","chunk":"ZWNobzpoZWxsbwo="}}
```
Terminate it:
```json
{"id":4,"method":"command/exec/terminate","params":{"processId":"proc-1"}}
{"id":4,"method":"process/terminate","params":{"processId":"proc-1"}}
{"id":4,"result":{"running":true}}
{"method":"command/exec/exited","params":{"processId":"proc-1","exitCode":0}}
{"method":"process/exited","params":{"processId":"proc-1","seq":3,"exitCode":0}}
{"method":"process/closed","params":{"processId":"proc-1"}}
```
@@ -1,18 +0,0 @@
use clap::Parser;
#[derive(Debug, Parser)]
struct ExecServerArgs {
/// Transport endpoint URL. Supported values: `ws://IP:PORT` (default).
#[arg(
long = "listen",
value_name = "URL",
default_value = codex_exec_server::DEFAULT_LISTEN_URL
)]
listen: String,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let args = ExecServerArgs::parse();
codex_exec_server::run_main_with_listen_url(&args.listen).await
}
+78 -3
View File
@@ -4,6 +4,7 @@ use tokio::sync::OnceCell;
use crate::ExecServerClient;
use crate::ExecServerError;
use crate::ExecServerRuntimePaths;
use crate::RemoteExecServerConnectArgs;
use crate::file_system::ExecutorFileSystem;
use crate::local_file_system::LocalFileSystem;
@@ -21,6 +22,7 @@ pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL";
#[derive(Debug)]
pub struct EnvironmentManager {
exec_server_url: Option<String>,
local_runtime_paths: Option<ExecServerRuntimePaths>,
disabled: bool,
current_environment: OnceCell<Option<Arc<Environment>>>,
}
@@ -34,9 +36,19 @@ impl Default for EnvironmentManager {
impl EnvironmentManager {
/// Builds a manager from the raw `CODEX_EXEC_SERVER_URL` value.
pub fn new(exec_server_url: Option<String>) -> Self {
Self::new_with_runtime_paths(exec_server_url, /*local_runtime_paths*/ None)
}
/// Builds a manager from the raw `CODEX_EXEC_SERVER_URL` value and local
/// runtime paths used when creating local filesystem helpers.
pub fn new_with_runtime_paths(
exec_server_url: Option<String>,
local_runtime_paths: Option<ExecServerRuntimePaths>,
) -> Self {
let (exec_server_url, disabled) = normalize_exec_server_url(exec_server_url);
Self {
exec_server_url,
local_runtime_paths,
disabled,
current_environment: OnceCell::new(),
}
@@ -44,7 +56,18 @@ impl EnvironmentManager {
/// Builds a manager from process environment variables.
pub fn from_env() -> Self {
Self::new(std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok())
Self::from_env_with_runtime_paths(/*local_runtime_paths*/ None)
}
/// Builds a manager from process environment variables and local runtime
/// paths used when creating local filesystem helpers.
pub fn from_env_with_runtime_paths(
local_runtime_paths: Option<ExecServerRuntimePaths>,
) -> Self {
Self::new_with_runtime_paths(
std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok(),
local_runtime_paths,
)
}
/// Builds a manager from the currently selected environment, or from the
@@ -53,11 +76,13 @@ impl EnvironmentManager {
match environment {
Some(environment) => Self {
exec_server_url: environment.exec_server_url().map(str::to_owned),
local_runtime_paths: environment.local_runtime_paths().cloned(),
disabled: false,
current_environment: OnceCell::new(),
},
None => Self {
exec_server_url: None,
local_runtime_paths: None,
disabled: true,
current_environment: OnceCell::new(),
},
@@ -82,7 +107,11 @@ impl EnvironmentManager {
Ok(None)
} else {
Ok(Some(Arc::new(
Environment::create(self.exec_server_url.clone()).await?,
Environment::create_with_runtime_paths(
self.exec_server_url.clone(),
self.local_runtime_paths.clone(),
)
.await?,
)))
}
})
@@ -101,6 +130,7 @@ pub struct Environment {
exec_server_url: Option<String>,
remote_exec_server_client: Option<ExecServerClient>,
exec_backend: Arc<dyn ExecBackend>,
local_runtime_paths: Option<ExecServerRuntimePaths>,
}
impl Default for Environment {
@@ -109,6 +139,7 @@ impl Default for Environment {
exec_server_url: None,
remote_exec_server_client: None,
exec_backend: Arc::new(LocalProcess::default()),
local_runtime_paths: None,
}
}
}
@@ -124,6 +155,15 @@ impl std::fmt::Debug for Environment {
impl Environment {
/// Builds an environment from the raw `CODEX_EXEC_SERVER_URL` value.
pub async fn create(exec_server_url: Option<String>) -> Result<Self, ExecServerError> {
Self::create_with_runtime_paths(exec_server_url, /*local_runtime_paths*/ None).await
}
/// Builds an environment from the raw `CODEX_EXEC_SERVER_URL` value and
/// local runtime paths used when creating local filesystem helpers.
pub async fn create_with_runtime_paths(
exec_server_url: Option<String>,
local_runtime_paths: Option<ExecServerRuntimePaths>,
) -> Result<Self, ExecServerError> {
let (exec_server_url, disabled) = normalize_exec_server_url(exec_server_url);
if disabled {
return Err(ExecServerError::Protocol(
@@ -157,6 +197,7 @@ impl Environment {
exec_server_url,
remote_exec_server_client,
exec_backend,
local_runtime_paths,
})
}
@@ -169,6 +210,10 @@ impl Environment {
self.exec_server_url.as_deref()
}
pub fn local_runtime_paths(&self) -> Option<&ExecServerRuntimePaths> {
self.local_runtime_paths.as_ref()
}
pub fn get_exec_backend(&self) -> Arc<dyn ExecBackend> {
Arc::clone(&self.exec_backend)
}
@@ -176,7 +221,10 @@ impl Environment {
pub fn get_filesystem(&self) -> Arc<dyn ExecutorFileSystem> {
match self.remote_exec_server_client.clone() {
Some(client) => Arc::new(RemoteFileSystem::new(client)),
None => Arc::new(LocalFileSystem),
None => match self.local_runtime_paths.clone() {
Some(runtime_paths) => Arc::new(LocalFileSystem::with_runtime_paths(runtime_paths)),
None => Arc::new(LocalFileSystem::unsandboxed()),
},
}
}
}
@@ -194,6 +242,7 @@ mod tests {
use super::Environment;
use super::EnvironmentManager;
use crate::ExecServerRuntimePaths;
use crate::ProcessId;
use pretty_assertions::assert_eq;
@@ -246,6 +295,31 @@ mod tests {
assert!(Arc::ptr_eq(&first, &second));
}
#[tokio::test]
async fn environment_manager_carries_local_runtime_paths() {
let runtime_paths = ExecServerRuntimePaths::new(
std::env::current_exe().expect("current exe"),
/*codex_linux_sandbox_exe*/ None,
)
.expect("runtime paths");
let manager = EnvironmentManager::new_with_runtime_paths(
/*exec_server_url*/ None,
Some(runtime_paths.clone()),
);
let environment = manager
.current()
.await
.expect("get current environment")
.expect("local environment");
assert_eq!(environment.local_runtime_paths(), Some(&runtime_paths));
assert_eq!(
EnvironmentManager::from_environment(Some(&environment)).local_runtime_paths,
Some(runtime_paths)
);
}
#[tokio::test]
async fn disabled_environment_manager_has_no_current_environment() {
let manager = EnvironmentManager::new(Some("none".to_string()));
@@ -269,6 +343,7 @@ mod tests {
process_id: ProcessId::from("default-env-proc"),
argv: vec!["true".to_string()],
cwd: std::env::current_dir().expect("read current dir"),
env_policy: None,
env: Default::default(),
tty: false,
arg0: None,
+53 -42
View File
@@ -1,4 +1,6 @@
use async_trait::async_trait;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use tokio::io;
@@ -34,86 +36,95 @@ pub struct ReadDirectoryEntry {
pub is_file: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FileSystemSandboxContext {
pub sandbox_policy: SandboxPolicy,
pub windows_sandbox_level: WindowsSandboxLevel,
#[serde(default)]
pub windows_sandbox_private_desktop: bool,
#[serde(default)]
pub use_legacy_landlock: bool,
pub additional_permissions: Option<PermissionProfile>,
}
impl FileSystemSandboxContext {
pub fn new(sandbox_policy: SandboxPolicy) -> Self {
Self {
sandbox_policy,
windows_sandbox_level: WindowsSandboxLevel::Disabled,
windows_sandbox_private_desktop: false,
use_legacy_landlock: false,
additional_permissions: None,
}
}
pub fn should_run_in_sandbox(&self) -> bool {
matches!(
self.sandbox_policy,
SandboxPolicy::ReadOnly { .. } | SandboxPolicy::WorkspaceWrite { .. }
)
}
}
pub type FileSystemResult<T> = io::Result<T>;
#[async_trait]
pub trait ExecutorFileSystem: Send + Sync {
async fn read_file(&self, path: &AbsolutePathBuf) -> FileSystemResult<Vec<u8>>;
async fn read_file(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<Vec<u8>>;
/// Reads a file and decodes it as UTF-8 text.
async fn read_file_text(&self, path: &AbsolutePathBuf) -> FileSystemResult<String> {
let bytes = self.read_file(path).await?;
async fn read_file_text(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<String> {
let bytes = self.read_file(path, sandbox).await?;
String::from_utf8(bytes).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))
}
async fn read_file_with_sandbox_policy(
&self,
path: &AbsolutePathBuf,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<Vec<u8>>;
async fn write_file(&self, path: &AbsolutePathBuf, contents: Vec<u8>) -> FileSystemResult<()>;
async fn write_file_with_sandbox_policy(
async fn write_file(
&self,
path: &AbsolutePathBuf,
contents: Vec<u8>,
sandbox_policy: Option<&SandboxPolicy>,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()>;
async fn create_directory(
&self,
path: &AbsolutePathBuf,
options: CreateDirectoryOptions,
) -> FileSystemResult<()>;
async fn create_directory_with_sandbox_policy(
&self,
path: &AbsolutePathBuf,
create_directory_options: CreateDirectoryOptions,
sandbox_policy: Option<&SandboxPolicy>,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()>;
async fn get_metadata(&self, path: &AbsolutePathBuf) -> FileSystemResult<FileMetadata>;
async fn get_metadata_with_sandbox_policy(
async fn get_metadata(
&self,
path: &AbsolutePathBuf,
sandbox_policy: Option<&SandboxPolicy>,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<FileMetadata>;
async fn read_directory(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<Vec<ReadDirectoryEntry>>;
async fn read_directory_with_sandbox_policy(
&self,
path: &AbsolutePathBuf,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<Vec<ReadDirectoryEntry>>;
async fn remove(&self, path: &AbsolutePathBuf, options: RemoveOptions) -> FileSystemResult<()>;
async fn remove_with_sandbox_policy(
async fn remove(
&self,
path: &AbsolutePathBuf,
remove_options: RemoveOptions,
sandbox_policy: Option<&SandboxPolicy>,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()>;
async fn copy(
&self,
source_path: &AbsolutePathBuf,
destination_path: &AbsolutePathBuf,
options: CopyOptions,
) -> FileSystemResult<()>;
async fn copy_with_sandbox_policy(
&self,
source_path: &AbsolutePathBuf,
destination_path: &AbsolutePathBuf,
copy_options: CopyOptions,
sandbox_policy: Option<&SandboxPolicy>,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()>;
}
+299
View File
@@ -0,0 +1,299 @@
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use codex_app_server_protocol::JSONRPCErrorError;
use serde::Deserialize;
use serde::Serialize;
use tokio::io;
use crate::CopyOptions;
use crate::CreateDirectoryOptions;
use crate::ExecutorFileSystem;
use crate::RemoveOptions;
use crate::local_file_system::DirectFileSystem;
use crate::protocol::FS_COPY_METHOD;
use crate::protocol::FS_CREATE_DIRECTORY_METHOD;
use crate::protocol::FS_GET_METADATA_METHOD;
use crate::protocol::FS_READ_DIRECTORY_METHOD;
use crate::protocol::FS_READ_FILE_METHOD;
use crate::protocol::FS_REMOVE_METHOD;
use crate::protocol::FS_WRITE_FILE_METHOD;
use crate::protocol::FsCopyParams;
use crate::protocol::FsCopyResponse;
use crate::protocol::FsCreateDirectoryParams;
use crate::protocol::FsCreateDirectoryResponse;
use crate::protocol::FsGetMetadataParams;
use crate::protocol::FsGetMetadataResponse;
use crate::protocol::FsReadDirectoryEntry;
use crate::protocol::FsReadDirectoryParams;
use crate::protocol::FsReadDirectoryResponse;
use crate::protocol::FsReadFileParams;
use crate::protocol::FsReadFileResponse;
use crate::protocol::FsRemoveParams;
use crate::protocol::FsRemoveResponse;
use crate::protocol::FsWriteFileParams;
use crate::protocol::FsWriteFileResponse;
use crate::rpc::internal_error;
use crate::rpc::invalid_request;
use crate::rpc::not_found;
pub const CODEX_FS_HELPER_ARG1: &str = "--codex-run-as-fs-helper";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "operation", content = "params")]
pub(crate) enum FsHelperRequest {
#[serde(rename = "fs/readFile")]
ReadFile(FsReadFileParams),
#[serde(rename = "fs/writeFile")]
WriteFile(FsWriteFileParams),
#[serde(rename = "fs/createDirectory")]
CreateDirectory(FsCreateDirectoryParams),
#[serde(rename = "fs/getMetadata")]
GetMetadata(FsGetMetadataParams),
#[serde(rename = "fs/readDirectory")]
ReadDirectory(FsReadDirectoryParams),
#[serde(rename = "fs/remove")]
Remove(FsRemoveParams),
#[serde(rename = "fs/copy")]
Copy(FsCopyParams),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "status", content = "payload", rename_all = "camelCase")]
pub(crate) enum FsHelperResponse {
Ok(FsHelperPayload),
Error(JSONRPCErrorError),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "operation", content = "response")]
pub(crate) enum FsHelperPayload {
#[serde(rename = "fs/readFile")]
ReadFile(FsReadFileResponse),
#[serde(rename = "fs/writeFile")]
WriteFile(FsWriteFileResponse),
#[serde(rename = "fs/createDirectory")]
CreateDirectory(FsCreateDirectoryResponse),
#[serde(rename = "fs/getMetadata")]
GetMetadata(FsGetMetadataResponse),
#[serde(rename = "fs/readDirectory")]
ReadDirectory(FsReadDirectoryResponse),
#[serde(rename = "fs/remove")]
Remove(FsRemoveResponse),
#[serde(rename = "fs/copy")]
Copy(FsCopyResponse),
}
impl FsHelperPayload {
fn operation(&self) -> &'static str {
match self {
Self::ReadFile(_) => FS_READ_FILE_METHOD,
Self::WriteFile(_) => FS_WRITE_FILE_METHOD,
Self::CreateDirectory(_) => FS_CREATE_DIRECTORY_METHOD,
Self::GetMetadata(_) => FS_GET_METADATA_METHOD,
Self::ReadDirectory(_) => FS_READ_DIRECTORY_METHOD,
Self::Remove(_) => FS_REMOVE_METHOD,
Self::Copy(_) => FS_COPY_METHOD,
}
}
pub(crate) fn expect_read_file(self) -> Result<FsReadFileResponse, JSONRPCErrorError> {
match self {
Self::ReadFile(response) => Ok(response),
other => Err(unexpected_response(FS_READ_FILE_METHOD, other.operation())),
}
}
pub(crate) fn expect_write_file(self) -> Result<FsWriteFileResponse, JSONRPCErrorError> {
match self {
Self::WriteFile(response) => Ok(response),
other => Err(unexpected_response(FS_WRITE_FILE_METHOD, other.operation())),
}
}
pub(crate) fn expect_create_directory(
self,
) -> Result<FsCreateDirectoryResponse, JSONRPCErrorError> {
match self {
Self::CreateDirectory(response) => Ok(response),
other => Err(unexpected_response(
FS_CREATE_DIRECTORY_METHOD,
other.operation(),
)),
}
}
pub(crate) fn expect_get_metadata(self) -> Result<FsGetMetadataResponse, JSONRPCErrorError> {
match self {
Self::GetMetadata(response) => Ok(response),
other => Err(unexpected_response(
FS_GET_METADATA_METHOD,
other.operation(),
)),
}
}
pub(crate) fn expect_read_directory(
self,
) -> Result<FsReadDirectoryResponse, JSONRPCErrorError> {
match self {
Self::ReadDirectory(response) => Ok(response),
other => Err(unexpected_response(
FS_READ_DIRECTORY_METHOD,
other.operation(),
)),
}
}
pub(crate) fn expect_remove(self) -> Result<FsRemoveResponse, JSONRPCErrorError> {
match self {
Self::Remove(response) => Ok(response),
other => Err(unexpected_response(FS_REMOVE_METHOD, other.operation())),
}
}
pub(crate) fn expect_copy(self) -> Result<FsCopyResponse, JSONRPCErrorError> {
match self {
Self::Copy(response) => Ok(response),
other => Err(unexpected_response(FS_COPY_METHOD, other.operation())),
}
}
}
fn unexpected_response(expected: &str, actual: &str) -> JSONRPCErrorError {
internal_error(format!(
"unexpected fs sandbox helper response: expected {expected}, got {actual}"
))
}
pub(crate) async fn run_direct_request(
request: FsHelperRequest,
) -> Result<FsHelperPayload, JSONRPCErrorError> {
let file_system = DirectFileSystem;
match request {
FsHelperRequest::ReadFile(params) => {
let data = file_system
.read_file(&params.path, /*sandbox*/ None)
.await
.map_err(map_fs_error)?;
Ok(FsHelperPayload::ReadFile(FsReadFileResponse {
data_base64: STANDARD.encode(data),
}))
}
FsHelperRequest::WriteFile(params) => {
let bytes = STANDARD.decode(params.data_base64).map_err(|err| {
invalid_request(format!(
"{FS_WRITE_FILE_METHOD} requires valid base64 dataBase64: {err}"
))
})?;
file_system
.write_file(&params.path, bytes, /*sandbox*/ None)
.await
.map_err(map_fs_error)?;
Ok(FsHelperPayload::WriteFile(FsWriteFileResponse {}))
}
FsHelperRequest::CreateDirectory(params) => {
file_system
.create_directory(
&params.path,
CreateDirectoryOptions {
recursive: params.recursive.unwrap_or(true),
},
/*sandbox*/ None,
)
.await
.map_err(map_fs_error)?;
Ok(FsHelperPayload::CreateDirectory(
FsCreateDirectoryResponse {},
))
}
FsHelperRequest::GetMetadata(params) => {
let metadata = file_system
.get_metadata(&params.path, /*sandbox*/ None)
.await
.map_err(map_fs_error)?;
Ok(FsHelperPayload::GetMetadata(FsGetMetadataResponse {
is_directory: metadata.is_directory,
is_file: metadata.is_file,
created_at_ms: metadata.created_at_ms,
modified_at_ms: metadata.modified_at_ms,
}))
}
FsHelperRequest::ReadDirectory(params) => {
let entries = file_system
.read_directory(&params.path, /*sandbox*/ None)
.await
.map_err(map_fs_error)?
.into_iter()
.map(|entry| FsReadDirectoryEntry {
file_name: entry.file_name,
is_directory: entry.is_directory,
is_file: entry.is_file,
})
.collect();
Ok(FsHelperPayload::ReadDirectory(FsReadDirectoryResponse {
entries,
}))
}
FsHelperRequest::Remove(params) => {
file_system
.remove(
&params.path,
RemoveOptions {
recursive: params.recursive.unwrap_or(true),
force: params.force.unwrap_or(true),
},
/*sandbox*/ None,
)
.await
.map_err(map_fs_error)?;
Ok(FsHelperPayload::Remove(FsRemoveResponse {}))
}
FsHelperRequest::Copy(params) => {
file_system
.copy(
&params.source_path,
&params.destination_path,
CopyOptions {
recursive: params.recursive,
},
/*sandbox*/ None,
)
.await
.map_err(map_fs_error)?;
Ok(FsHelperPayload::Copy(FsCopyResponse {}))
}
}
}
fn map_fs_error(err: io::Error) -> JSONRPCErrorError {
match err.kind() {
io::ErrorKind::NotFound => not_found(err.to_string()),
io::ErrorKind::InvalidInput | io::ErrorKind::PermissionDenied => {
invalid_request(err.to_string())
}
_ => internal_error(err.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn helper_requests_use_fs_method_names() -> serde_json::Result<()> {
assert_eq!(
serde_json::to_value(FsHelperRequest::WriteFile(FsWriteFileParams {
path: std::env::current_dir()
.expect("cwd")
.join("file")
.as_path()
.try_into()
.expect("absolute path"),
data_base64: String::new(),
sandbox: None,
}))?["operation"],
FS_WRITE_FILE_METHOD,
);
Ok(())
}
}
@@ -0,0 +1,45 @@
use std::error::Error;
use tokio::io;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
use crate::fs_helper::FsHelperRequest;
use crate::fs_helper::FsHelperResponse;
use crate::fs_helper::run_direct_request;
pub fn main() -> ! {
let exit_code = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => match runtime.block_on(run_main()) {
Ok(()) => 0,
Err(err) => {
eprintln!("fs sandbox helper failed: {err}");
1
}
},
Err(err) => {
eprintln!("failed to start fs sandbox helper runtime: {err}");
1
}
};
std::process::exit(exit_code);
}
async fn run_main() -> Result<(), Box<dyn Error + Send + Sync>> {
let mut input = Vec::new();
io::stdin().read_to_end(&mut input).await?;
let request: FsHelperRequest = serde_json::from_slice(&input)?;
let response = match run_direct_request(request).await {
Ok(payload) => FsHelperResponse::Ok(payload),
Err(error) => FsHelperResponse::Error(error),
};
let mut stdout = io::stdout();
stdout
.write_all(serde_json::to_string(&response)?.as_bytes())
.await?;
stdout.write_all(b"\n").await?;
Ok(())
}
+546
View File
@@ -0,0 +1,546 @@
use std::collections::HashMap;
use std::path::PathBuf;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::ReadOnlyAccess;
use codex_protocol::protocol::SandboxPolicy;
use codex_sandboxing::SandboxCommand;
use codex_sandboxing::SandboxExecRequest;
use codex_sandboxing::SandboxManager;
use codex_sandboxing::SandboxTransformRequest;
use codex_sandboxing::SandboxablePreference;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::canonicalize_preserving_symlinks;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use crate::ExecServerRuntimePaths;
use crate::FileSystemSandboxContext;
use crate::fs_helper::CODEX_FS_HELPER_ARG1;
use crate::fs_helper::FsHelperPayload;
use crate::fs_helper::FsHelperRequest;
use crate::fs_helper::FsHelperResponse;
use crate::local_file_system::current_sandbox_cwd;
use crate::local_file_system::resolve_existing_path;
use crate::protocol::FsCopyParams;
use crate::protocol::FsCreateDirectoryParams;
use crate::protocol::FsGetMetadataParams;
use crate::protocol::FsReadDirectoryParams;
use crate::protocol::FsReadFileParams;
use crate::protocol::FsRemoveParams;
use crate::protocol::FsWriteFileParams;
use crate::rpc::internal_error;
use crate::rpc::invalid_request;
#[derive(Clone, Debug)]
pub(crate) struct FileSystemSandboxRunner {
runtime_paths: ExecServerRuntimePaths,
}
impl FileSystemSandboxRunner {
pub(crate) fn new(runtime_paths: ExecServerRuntimePaths) -> Self {
Self { runtime_paths }
}
pub(crate) async fn run(
&self,
sandbox: &FileSystemSandboxContext,
request: FsHelperRequest,
) -> Result<FsHelperPayload, JSONRPCErrorError> {
let request_sandbox_policy =
normalize_sandbox_policy_root_aliases(sandbox.sandbox_policy.clone());
let helper_sandbox_policy = normalize_sandbox_policy_root_aliases(
sandbox_policy_with_helper_runtime_defaults(&sandbox.sandbox_policy),
);
let cwd = current_sandbox_cwd().map_err(io_error)?;
let cwd = AbsolutePathBuf::from_absolute_path(cwd.as_path())
.map_err(|err| invalid_request(format!("current directory is not absolute: {err}")))?;
let request_file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy(
&request_sandbox_policy,
cwd.as_path(),
);
let file_system_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy(
&helper_sandbox_policy,
cwd.as_path(),
);
let request = resolve_request_paths(request, &request_file_system_policy, &cwd)?;
let network_policy = NetworkSandboxPolicy::Restricted;
let command = self.sandbox_exec_request(
&helper_sandbox_policy,
&file_system_policy,
network_policy,
&cwd,
sandbox,
)?;
let request_json = serde_json::to_vec(&request).map_err(json_error)?;
run_command(command, request_json).await
}
fn sandbox_exec_request(
&self,
sandbox_policy: &SandboxPolicy,
file_system_policy: &FileSystemSandboxPolicy,
network_policy: NetworkSandboxPolicy,
cwd: &AbsolutePathBuf,
sandbox_context: &FileSystemSandboxContext,
) -> Result<SandboxExecRequest, JSONRPCErrorError> {
let helper = &self.runtime_paths.codex_self_exe;
let sandbox_manager = SandboxManager::new();
let sandbox = sandbox_manager.select_initial(
file_system_policy,
network_policy,
SandboxablePreference::Auto,
sandbox_context.windows_sandbox_level,
/*has_managed_network_requirements*/ false,
);
let command = SandboxCommand {
program: helper.as_path().as_os_str().to_owned(),
args: vec![CODEX_FS_HELPER_ARG1.to_string()],
cwd: cwd.clone(),
env: HashMap::new(),
additional_permissions: Some(
self.helper_permissions(sandbox_context.additional_permissions.as_ref()),
),
};
sandbox_manager
.transform(SandboxTransformRequest {
command,
policy: sandbox_policy,
file_system_policy,
network_policy,
sandbox,
enforce_managed_network: false,
network: None,
sandbox_policy_cwd: cwd.as_path(),
codex_linux_sandbox_exe: self.runtime_paths.codex_linux_sandbox_exe.as_deref(),
use_legacy_landlock: sandbox_context.use_legacy_landlock,
windows_sandbox_level: sandbox_context.windows_sandbox_level,
windows_sandbox_private_desktop: sandbox_context.windows_sandbox_private_desktop,
})
.map_err(|err| invalid_request(format!("failed to prepare fs sandbox: {err}")))
}
fn helper_permissions(
&self,
additional_permissions: Option<&PermissionProfile>,
) -> PermissionProfile {
PermissionProfile {
network: None,
file_system: additional_permissions
.and_then(|permissions| permissions.file_system.clone()),
}
}
}
fn resolve_request_paths(
request: FsHelperRequest,
file_system_policy: &FileSystemSandboxPolicy,
cwd: &AbsolutePathBuf,
) -> Result<FsHelperRequest, JSONRPCErrorError> {
match request {
FsHelperRequest::ReadFile(FsReadFileParams { path, sandbox }) => {
let path = resolve_sandbox_path(&path, PreserveTerminalSymlink::No)?;
ensure_path_access(file_system_policy, cwd, &path, FileSystemAccessMode::Read)?;
Ok(FsHelperRequest::ReadFile(FsReadFileParams {
path,
sandbox,
}))
}
FsHelperRequest::WriteFile(FsWriteFileParams {
path,
data_base64,
sandbox,
}) => Ok(FsHelperRequest::WriteFile(FsWriteFileParams {
path: {
let path = resolve_sandbox_path(&path, PreserveTerminalSymlink::No)?;
ensure_path_access(file_system_policy, cwd, &path, FileSystemAccessMode::Write)?;
path
},
data_base64,
sandbox,
})),
FsHelperRequest::CreateDirectory(FsCreateDirectoryParams {
path,
recursive,
sandbox,
}) => Ok(FsHelperRequest::CreateDirectory(FsCreateDirectoryParams {
path: {
let path = resolve_sandbox_path(&path, PreserveTerminalSymlink::No)?;
ensure_path_access(file_system_policy, cwd, &path, FileSystemAccessMode::Write)?;
path
},
recursive,
sandbox,
})),
FsHelperRequest::GetMetadata(FsGetMetadataParams { path, sandbox }) => {
let path = resolve_sandbox_path(&path, PreserveTerminalSymlink::No)?;
ensure_path_access(file_system_policy, cwd, &path, FileSystemAccessMode::Read)?;
Ok(FsHelperRequest::GetMetadata(FsGetMetadataParams {
path,
sandbox,
}))
}
FsHelperRequest::ReadDirectory(FsReadDirectoryParams { path, sandbox }) => {
let path = resolve_sandbox_path(&path, PreserveTerminalSymlink::No)?;
ensure_path_access(file_system_policy, cwd, &path, FileSystemAccessMode::Read)?;
Ok(FsHelperRequest::ReadDirectory(FsReadDirectoryParams {
path,
sandbox,
}))
}
FsHelperRequest::Remove(FsRemoveParams {
path,
recursive,
force,
sandbox,
}) => Ok(FsHelperRequest::Remove(FsRemoveParams {
path: {
let path = resolve_sandbox_path(&path, PreserveTerminalSymlink::Yes)?;
ensure_path_access(file_system_policy, cwd, &path, FileSystemAccessMode::Write)?;
path
},
recursive,
force,
sandbox,
})),
FsHelperRequest::Copy(FsCopyParams {
source_path,
destination_path,
recursive,
sandbox,
}) => Ok(FsHelperRequest::Copy(FsCopyParams {
source_path: {
let source_path = resolve_sandbox_path(&source_path, PreserveTerminalSymlink::Yes)?;
ensure_path_access(
file_system_policy,
cwd,
&source_path,
FileSystemAccessMode::Read,
)?;
source_path
},
destination_path: {
let destination_path =
resolve_sandbox_path(&destination_path, PreserveTerminalSymlink::No)?;
ensure_path_access(
file_system_policy,
cwd,
&destination_path,
FileSystemAccessMode::Write,
)?;
destination_path
},
recursive,
sandbox,
})),
}
}
#[derive(Clone, Copy)]
enum PreserveTerminalSymlink {
Yes,
No,
}
fn resolve_sandbox_path(
path: &AbsolutePathBuf,
preserve_terminal_symlink: PreserveTerminalSymlink,
) -> Result<AbsolutePathBuf, JSONRPCErrorError> {
if matches!(preserve_terminal_symlink, PreserveTerminalSymlink::Yes)
&& std::fs::symlink_metadata(path.as_path())
.map(|metadata| metadata.file_type().is_symlink())
.unwrap_or(false)
{
return Ok(normalize_top_level_alias(path.clone()));
}
let resolved = resolve_existing_path(path.as_path()).map_err(io_error)?;
absolute_path(resolved)
}
fn normalize_sandbox_policy_root_aliases(sandbox_policy: SandboxPolicy) -> SandboxPolicy {
let mut sandbox_policy = sandbox_policy;
match &mut sandbox_policy {
SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::Restricted { readable_roots, .. },
..
} => {
normalize_root_aliases(readable_roots);
}
SandboxPolicy::WorkspaceWrite {
writable_roots,
read_only_access,
..
} => {
normalize_root_aliases(writable_roots);
if let ReadOnlyAccess::Restricted { readable_roots, .. } = read_only_access {
normalize_root_aliases(readable_roots);
}
}
_ => {}
}
sandbox_policy
}
fn normalize_root_aliases(paths: &mut Vec<AbsolutePathBuf>) {
for path in paths {
*path = normalize_top_level_alias(path.clone());
}
}
fn normalize_top_level_alias(path: AbsolutePathBuf) -> AbsolutePathBuf {
let raw_path = path.to_path_buf();
for ancestor in raw_path.ancestors() {
if std::fs::symlink_metadata(ancestor).is_err() {
continue;
}
let Ok(normalized_ancestor) = canonicalize_preserving_symlinks(ancestor) else {
continue;
};
if normalized_ancestor == ancestor {
continue;
}
let Ok(suffix) = raw_path.strip_prefix(ancestor) else {
continue;
};
if let Ok(normalized_path) =
AbsolutePathBuf::from_absolute_path(normalized_ancestor.join(suffix))
{
return normalized_path;
}
}
path
}
fn absolute_path(path: PathBuf) -> Result<AbsolutePathBuf, JSONRPCErrorError> {
AbsolutePathBuf::from_absolute_path(path.as_path())
.map_err(|err| invalid_request(format!("resolved sandbox path is not absolute: {err}")))
}
fn ensure_path_access(
file_system_policy: &FileSystemSandboxPolicy,
cwd: &AbsolutePathBuf,
path: &AbsolutePathBuf,
required_access: FileSystemAccessMode,
) -> Result<(), JSONRPCErrorError> {
let actual_access = file_system_policy.resolve_access_with_cwd(path.as_path(), cwd.as_path());
let permitted = match required_access {
FileSystemAccessMode::Read => actual_access.can_read(),
FileSystemAccessMode::Write => actual_access.can_write(),
FileSystemAccessMode::None => true,
};
if permitted {
return Ok(());
}
Err(invalid_request(format!(
"{} is not permitted by filesystem sandbox",
path.display()
)))
}
async fn run_command(
command: SandboxExecRequest,
request_json: Vec<u8>,
) -> Result<FsHelperPayload, JSONRPCErrorError> {
let mut child = spawn_command(command)?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| internal_error("failed to open fs sandbox helper stdin".to_string()))?;
stdin.write_all(&request_json).await.map_err(io_error)?;
stdin.shutdown().await.map_err(io_error)?;
drop(stdin);
let output = child.wait_with_output().await.map_err(io_error)?;
if !output.status.success() {
return Err(internal_error(format!(
"fs sandbox helper failed with status {status}: {stderr}",
status = output.status,
stderr = String::from_utf8_lossy(&output.stderr).trim()
)));
}
let response: FsHelperResponse = serde_json::from_slice(&output.stdout).map_err(json_error)?;
match response {
FsHelperResponse::Ok(payload) => Ok(payload),
FsHelperResponse::Error(error) => Err(error),
}
}
fn spawn_command(
SandboxExecRequest {
command: argv,
cwd,
env,
arg0,
..
}: SandboxExecRequest,
) -> Result<tokio::process::Child, JSONRPCErrorError> {
let Some((program, args)) = argv.split_first() else {
return Err(invalid_request("fs sandbox command was empty".to_string()));
};
let mut command = Command::new(program);
#[cfg(unix)]
if let Some(arg0) = arg0 {
command.arg0(arg0);
}
#[cfg(not(unix))]
let _ = arg0;
command.args(args);
command.current_dir(cwd.as_path());
command.env_clear();
command.envs(env);
command.stdin(std::process::Stdio::piped());
command.stdout(std::process::Stdio::piped());
command.stderr(std::process::Stdio::piped());
command.spawn().map_err(io_error)
}
fn sandbox_policy_with_helper_runtime_defaults(sandbox_policy: &SandboxPolicy) -> SandboxPolicy {
let mut sandbox_policy = sandbox_policy.clone();
match &mut sandbox_policy {
SandboxPolicy::ReadOnly { access, .. } => enable_platform_defaults(access),
SandboxPolicy::WorkspaceWrite {
read_only_access, ..
} => enable_platform_defaults(read_only_access),
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {}
}
sandbox_policy
}
fn enable_platform_defaults(access: &mut ReadOnlyAccess) {
if let ReadOnlyAccess::Restricted {
include_platform_defaults,
..
} = access
{
*include_platform_defaults = true;
}
}
fn io_error(err: std::io::Error) -> JSONRPCErrorError {
internal_error(err.to_string())
}
fn json_error(err: serde_json::Error) -> JSONRPCErrorError {
internal_error(format!(
"failed to encode or decode fs sandbox helper message: {err}"
))
}
#[cfg(test)]
mod tests {
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::models::NetworkPermissions;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::ReadOnlyAccess;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use crate::ExecServerRuntimePaths;
use super::FileSystemSandboxRunner;
use super::sandbox_policy_with_helper_runtime_defaults;
#[test]
fn helper_sandbox_policy_enables_platform_defaults_for_read_only_access() {
let sandbox_policy = SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::Restricted {
include_platform_defaults: false,
readable_roots: Vec::new(),
},
network_access: false,
};
let updated = sandbox_policy_with_helper_runtime_defaults(&sandbox_policy);
assert_eq!(
updated,
SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::Restricted {
include_platform_defaults: true,
readable_roots: Vec::new(),
},
network_access: false,
}
);
}
#[test]
fn helper_sandbox_policy_enables_platform_defaults_for_workspace_read_access() {
let sandbox_policy = 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: true,
exclude_slash_tmp: true,
};
let updated = sandbox_policy_with_helper_runtime_defaults(&sandbox_policy);
assert_eq!(
updated,
SandboxPolicy::WorkspaceWrite {
writable_roots: Vec::new(),
read_only_access: ReadOnlyAccess::Restricted {
include_platform_defaults: true,
readable_roots: Vec::new(),
},
network_access: false,
exclude_tmpdir_env_var: true,
exclude_slash_tmp: true,
}
);
}
#[test]
fn helper_permissions_strip_network_grants() {
let codex_self_exe = std::env::current_exe().expect("current exe");
let runtime_paths = ExecServerRuntimePaths::new(
codex_self_exe.clone(),
/*codex_linux_sandbox_exe*/ None,
)
.expect("runtime paths");
let runner = FileSystemSandboxRunner::new(runtime_paths);
let readable = AbsolutePathBuf::from_absolute_path(
codex_self_exe.parent().expect("current exe parent"),
)
.expect("absolute readable path");
let writable = AbsolutePathBuf::from_absolute_path(std::env::temp_dir().as_path())
.expect("absolute writable path");
let permissions = runner.helper_permissions(Some(&PermissionProfile {
network: Some(NetworkPermissions {
enabled: Some(true),
}),
file_system: Some(FileSystemPermissions {
read: Some(vec![readable.clone()]),
write: Some(vec![writable.clone()]),
}),
}));
assert_eq!(permissions.network, None);
assert_eq!(
permissions
.file_system
.as_ref()
.and_then(|fs| fs.write.clone()),
Some(vec![writable])
);
assert_eq!(
permissions
.file_system
.as_ref()
.and_then(|fs| fs.read.clone()),
Some(vec![readable])
);
}
}
+11 -1
View File
@@ -3,6 +3,9 @@ mod client_api;
mod connection;
mod environment;
mod file_system;
mod fs_helper;
mod fs_helper_main;
mod fs_sandbox;
mod local_file_system;
mod local_process;
mod process;
@@ -11,6 +14,8 @@ mod protocol;
mod remote_file_system;
mod remote_process;
mod rpc;
mod runtime_paths;
mod sandboxed_file_system;
mod server;
pub use client::ExecServerClient;
@@ -25,14 +30,19 @@ pub use file_system::CreateDirectoryOptions;
pub use file_system::ExecutorFileSystem;
pub use file_system::FileMetadata;
pub use file_system::FileSystemResult;
pub use file_system::FileSystemSandboxContext;
pub use file_system::ReadDirectoryEntry;
pub use file_system::RemoveOptions;
pub use fs_helper::CODEX_FS_HELPER_ARG1;
pub use fs_helper_main::main as run_fs_helper_main;
pub use local_file_system::LOCAL_FS;
pub use local_file_system::LocalFileSystem;
pub use process::ExecBackend;
pub use process::ExecProcess;
pub use process::StartedExecProcess;
pub use process_id::ProcessId;
pub use protocol::ExecClosedNotification;
pub use protocol::ExecEnvPolicy;
pub use protocol::ExecExitedNotification;
pub use protocol::ExecOutputDeltaNotification;
pub use protocol::ExecOutputStream;
@@ -62,7 +72,7 @@ pub use protocol::TerminateResponse;
pub use protocol::WriteParams;
pub use protocol::WriteResponse;
pub use protocol::WriteStatus;
pub use runtime_paths::ExecServerRuntimePaths;
pub use server::DEFAULT_LISTEN_URL;
pub use server::ExecServerListenUrlParseError;
pub use server::run_main;
pub use server::run_main_with_listen_url;
+258 -296
View File
@@ -1,7 +1,4 @@
use async_trait::async_trait;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::path::Path;
use std::path::PathBuf;
@@ -13,23 +10,240 @@ use tokio::io;
use crate::CopyOptions;
use crate::CreateDirectoryOptions;
use crate::ExecServerRuntimePaths;
use crate::ExecutorFileSystem;
use crate::FileMetadata;
use crate::FileSystemResult;
use crate::FileSystemSandboxContext;
use crate::ReadDirectoryEntry;
use crate::RemoveOptions;
use crate::sandboxed_file_system::SandboxedFileSystem;
const MAX_READ_FILE_BYTES: u64 = 512 * 1024 * 1024;
pub static LOCAL_FS: LazyLock<Arc<dyn ExecutorFileSystem>> =
LazyLock::new(|| -> Arc<dyn ExecutorFileSystem> { Arc::new(LocalFileSystem) });
LazyLock::new(|| -> Arc<dyn ExecutorFileSystem> { Arc::new(LocalFileSystem::unsandboxed()) });
#[derive(Clone, Default)]
pub(crate) struct LocalFileSystem;
pub(crate) struct DirectFileSystem;
#[derive(Clone, Default)]
pub(crate) struct UnsandboxedFileSystem {
file_system: DirectFileSystem,
}
#[derive(Clone, Default)]
pub struct LocalFileSystem {
unsandboxed: UnsandboxedFileSystem,
sandboxed: Option<SandboxedFileSystem>,
}
impl LocalFileSystem {
pub fn unsandboxed() -> Self {
Self {
unsandboxed: UnsandboxedFileSystem::default(),
sandboxed: None,
}
}
pub fn with_runtime_paths(runtime_paths: ExecServerRuntimePaths) -> Self {
Self {
unsandboxed: UnsandboxedFileSystem::default(),
sandboxed: Some(SandboxedFileSystem::new(runtime_paths)),
}
}
fn sandboxed(&self) -> io::Result<&SandboxedFileSystem> {
self.sandboxed.as_ref().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"sandboxed filesystem operations require configured runtime paths",
)
})
}
fn file_system_for<'a>(
&'a self,
sandbox: Option<&'a FileSystemSandboxContext>,
) -> io::Result<(
&'a dyn ExecutorFileSystem,
Option<&'a FileSystemSandboxContext>,
)> {
if sandbox.is_some_and(FileSystemSandboxContext::should_run_in_sandbox) {
Ok((self.sandboxed()?, sandbox))
} else {
Ok((&self.unsandboxed, sandbox))
}
}
}
#[async_trait]
impl ExecutorFileSystem for LocalFileSystem {
async fn read_file(&self, path: &AbsolutePathBuf) -> FileSystemResult<Vec<u8>> {
async fn read_file(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<Vec<u8>> {
let (file_system, sandbox) = self.file_system_for(sandbox)?;
file_system.read_file(path, sandbox).await
}
async fn write_file(
&self,
path: &AbsolutePathBuf,
contents: Vec<u8>,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
let (file_system, sandbox) = self.file_system_for(sandbox)?;
file_system.write_file(path, contents, sandbox).await
}
async fn create_directory(
&self,
path: &AbsolutePathBuf,
options: CreateDirectoryOptions,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
let (file_system, sandbox) = self.file_system_for(sandbox)?;
file_system.create_directory(path, options, sandbox).await
}
async fn get_metadata(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<FileMetadata> {
let (file_system, sandbox) = self.file_system_for(sandbox)?;
file_system.get_metadata(path, sandbox).await
}
async fn read_directory(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<Vec<ReadDirectoryEntry>> {
let (file_system, sandbox) = self.file_system_for(sandbox)?;
file_system.read_directory(path, sandbox).await
}
async fn remove(
&self,
path: &AbsolutePathBuf,
options: RemoveOptions,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
let (file_system, sandbox) = self.file_system_for(sandbox)?;
file_system.remove(path, options, sandbox).await
}
async fn copy(
&self,
source_path: &AbsolutePathBuf,
destination_path: &AbsolutePathBuf,
options: CopyOptions,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
let (file_system, sandbox) = self.file_system_for(sandbox)?;
file_system
.copy(source_path, destination_path, options, sandbox)
.await
}
}
#[async_trait]
impl ExecutorFileSystem for UnsandboxedFileSystem {
async fn read_file(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<Vec<u8>> {
reject_platform_sandbox_context(sandbox)?;
self.file_system.read_file(path, /*sandbox*/ None).await
}
async fn write_file(
&self,
path: &AbsolutePathBuf,
contents: Vec<u8>,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
reject_platform_sandbox_context(sandbox)?;
self.file_system
.write_file(path, contents, /*sandbox*/ None)
.await
}
async fn create_directory(
&self,
path: &AbsolutePathBuf,
options: CreateDirectoryOptions,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
reject_platform_sandbox_context(sandbox)?;
self.file_system
.create_directory(path, options, /*sandbox*/ None)
.await
}
async fn get_metadata(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<FileMetadata> {
reject_platform_sandbox_context(sandbox)?;
self.file_system.get_metadata(path, /*sandbox*/ None).await
}
async fn read_directory(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<Vec<ReadDirectoryEntry>> {
reject_platform_sandbox_context(sandbox)?;
self.file_system
.read_directory(path, /*sandbox*/ None)
.await
}
async fn remove(
&self,
path: &AbsolutePathBuf,
options: RemoveOptions,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
reject_platform_sandbox_context(sandbox)?;
self.file_system
.remove(path, options, /*sandbox*/ None)
.await
}
async fn copy(
&self,
source_path: &AbsolutePathBuf,
destination_path: &AbsolutePathBuf,
options: CopyOptions,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
reject_platform_sandbox_context(sandbox)?;
self.file_system
.copy(
source_path,
destination_path,
options,
/*sandbox*/ None,
)
.await
}
}
#[async_trait]
impl ExecutorFileSystem for DirectFileSystem {
async fn read_file(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<Vec<u8>> {
reject_sandbox_context(sandbox)?;
let metadata = tokio::fs::metadata(path.as_path()).await?;
if metadata.len() > MAX_READ_FILE_BYTES {
return Err(io::Error::new(
@@ -40,34 +254,23 @@ impl ExecutorFileSystem for LocalFileSystem {
tokio::fs::read(path.as_path()).await
}
async fn read_file_with_sandbox_policy(
&self,
path: &AbsolutePathBuf,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<Vec<u8>> {
enforce_read_access(path, sandbox_policy)?;
self.read_file(path).await
}
async fn write_file(&self, path: &AbsolutePathBuf, contents: Vec<u8>) -> FileSystemResult<()> {
tokio::fs::write(path.as_path(), contents).await
}
async fn write_file_with_sandbox_policy(
async fn write_file(
&self,
path: &AbsolutePathBuf,
contents: Vec<u8>,
sandbox_policy: Option<&SandboxPolicy>,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
enforce_write_access(path, sandbox_policy)?;
self.write_file(path, contents).await
reject_sandbox_context(sandbox)?;
tokio::fs::write(path.as_path(), contents).await
}
async fn create_directory(
&self,
path: &AbsolutePathBuf,
options: CreateDirectoryOptions,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
reject_sandbox_context(sandbox)?;
if options.recursive {
tokio::fs::create_dir_all(path.as_path()).await?;
} else {
@@ -76,17 +279,12 @@ impl ExecutorFileSystem for LocalFileSystem {
Ok(())
}
async fn create_directory_with_sandbox_policy(
async fn get_metadata(
&self,
path: &AbsolutePathBuf,
create_directory_options: CreateDirectoryOptions,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<()> {
enforce_write_access(path, sandbox_policy)?;
self.create_directory(path, create_directory_options).await
}
async fn get_metadata(&self, path: &AbsolutePathBuf) -> FileSystemResult<FileMetadata> {
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<FileMetadata> {
reject_sandbox_context(sandbox)?;
let metadata = tokio::fs::metadata(path.as_path()).await?;
Ok(FileMetadata {
is_directory: metadata.is_dir(),
@@ -96,19 +294,12 @@ impl ExecutorFileSystem for LocalFileSystem {
})
}
async fn get_metadata_with_sandbox_policy(
&self,
path: &AbsolutePathBuf,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<FileMetadata> {
enforce_read_access(path, sandbox_policy)?;
self.get_metadata(path).await
}
async fn read_directory(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<Vec<ReadDirectoryEntry>> {
reject_sandbox_context(sandbox)?;
let mut entries = Vec::new();
let mut read_dir = tokio::fs::read_dir(path.as_path()).await?;
while let Some(entry) = read_dir.next_entry().await? {
@@ -122,16 +313,13 @@ impl ExecutorFileSystem for LocalFileSystem {
Ok(entries)
}
async fn read_directory_with_sandbox_policy(
async fn remove(
&self,
path: &AbsolutePathBuf,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<Vec<ReadDirectoryEntry>> {
enforce_read_access(path, sandbox_policy)?;
self.read_directory(path).await
}
async fn remove(&self, path: &AbsolutePathBuf, options: RemoveOptions) -> FileSystemResult<()> {
options: RemoveOptions,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
reject_sandbox_context(sandbox)?;
match tokio::fs::symlink_metadata(path.as_path()).await {
Ok(metadata) => {
let file_type = metadata.file_type();
@@ -151,22 +339,14 @@ impl ExecutorFileSystem for LocalFileSystem {
}
}
async fn remove_with_sandbox_policy(
&self,
path: &AbsolutePathBuf,
remove_options: RemoveOptions,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<()> {
enforce_write_access_preserving_leaf(path, sandbox_policy)?;
self.remove(path, remove_options).await
}
async fn copy(
&self,
source_path: &AbsolutePathBuf,
destination_path: &AbsolutePathBuf,
options: CopyOptions,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
reject_sandbox_context(sandbox)?;
let source_path = source_path.to_path_buf();
let destination_path = destination_path.to_path_buf();
tokio::task::spawn_blocking(move || -> FileSystemResult<()> {
@@ -211,164 +391,26 @@ impl ExecutorFileSystem for LocalFileSystem {
.await
.map_err(|err| io::Error::other(format!("filesystem task failed: {err}")))?
}
async fn copy_with_sandbox_policy(
&self,
source_path: &AbsolutePathBuf,
destination_path: &AbsolutePathBuf,
copy_options: CopyOptions,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<()> {
enforce_copy_source_read_access(source_path, sandbox_policy)?;
enforce_write_access(destination_path, sandbox_policy)?;
self.copy(source_path, destination_path, copy_options).await
}
}
fn enforce_read_access(
path: &AbsolutePathBuf,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<()> {
enforce_access_for_current_dir(
path,
sandbox_policy,
FileSystemSandboxPolicy::can_read_path_with_cwd,
"read",
AccessPathMode::ResolveAll,
)
}
fn enforce_write_access(
path: &AbsolutePathBuf,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<()> {
enforce_access_for_current_dir(
path,
sandbox_policy,
FileSystemSandboxPolicy::can_write_path_with_cwd,
"write",
AccessPathMode::ResolveAll,
)
}
fn enforce_write_access_preserving_leaf(
path: &AbsolutePathBuf,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<()> {
enforce_access_for_current_dir(
path,
sandbox_policy,
FileSystemSandboxPolicy::can_write_path_with_cwd,
"write",
AccessPathMode::PreserveLeaf,
)
}
fn enforce_copy_source_read_access(
path: &AbsolutePathBuf,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<()> {
let path_mode = match std::fs::symlink_metadata(path.as_path()) {
Ok(metadata) if metadata.file_type().is_symlink() => AccessPathMode::PreserveLeaf,
_ => AccessPathMode::ResolveAll,
};
enforce_access_for_current_dir(
path,
sandbox_policy,
FileSystemSandboxPolicy::can_read_path_with_cwd,
"read",
path_mode,
)
}
#[cfg(all(test, unix))]
fn enforce_read_access_for_cwd(
path: &AbsolutePathBuf,
sandbox_policy: Option<&SandboxPolicy>,
sandbox_cwd: &AbsolutePathBuf,
) -> FileSystemResult<()> {
enforce_access_for_cwd(
path,
sandbox_policy,
sandbox_cwd,
FileSystemSandboxPolicy::can_read_path_with_cwd,
"read",
AccessPathMode::ResolveAll,
)
}
fn enforce_access_for_current_dir(
path: &AbsolutePathBuf,
sandbox_policy: Option<&SandboxPolicy>,
is_allowed: fn(&FileSystemSandboxPolicy, &Path, &Path) -> bool,
access_kind: &str,
path_mode: AccessPathMode,
) -> FileSystemResult<()> {
let Some(sandbox_policy) = sandbox_policy else {
return Ok(());
};
let cwd = current_sandbox_cwd()?;
enforce_access(
path,
sandbox_policy,
cwd.as_path(),
is_allowed,
access_kind,
path_mode,
)
}
#[cfg(all(test, unix))]
fn enforce_access_for_cwd(
path: &AbsolutePathBuf,
sandbox_policy: Option<&SandboxPolicy>,
sandbox_cwd: &AbsolutePathBuf,
is_allowed: fn(&FileSystemSandboxPolicy, &Path, &Path) -> bool,
access_kind: &str,
path_mode: AccessPathMode,
) -> FileSystemResult<()> {
let Some(sandbox_policy) = sandbox_policy else {
return Ok(());
};
let cwd = resolve_existing_path(sandbox_cwd.as_path())?;
enforce_access(
path,
sandbox_policy,
cwd.as_path(),
is_allowed,
access_kind,
path_mode,
)
}
fn enforce_access(
path: &AbsolutePathBuf,
sandbox_policy: &SandboxPolicy,
sandbox_cwd: &Path,
is_allowed: fn(&FileSystemSandboxPolicy, &Path, &Path) -> bool,
access_kind: &str,
path_mode: AccessPathMode,
) -> FileSystemResult<()> {
let resolved_path = resolve_path_for_access_check(path.as_path(), path_mode)?;
let file_system_policy =
canonicalize_file_system_policy_paths(FileSystemSandboxPolicy::from(sandbox_policy))?;
if is_allowed(&file_system_policy, resolved_path.as_path(), sandbox_cwd) {
Ok(())
} else {
Err(io::Error::new(
fn reject_sandbox_context(sandbox: Option<&FileSystemSandboxContext>) -> io::Result<()> {
if sandbox.is_some() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"fs/{access_kind} is not permitted for path {}",
path.as_path().display()
),
))
"direct filesystem operations do not accept sandbox context",
));
}
Ok(())
}
#[derive(Clone, Copy)]
enum AccessPathMode {
ResolveAll,
PreserveLeaf,
fn reject_platform_sandbox_context(sandbox: Option<&FileSystemSandboxContext>) -> io::Result<()> {
if sandbox.is_some_and(FileSystemSandboxContext::should_run_in_sandbox) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"sandboxed filesystem operations require configured runtime paths",
));
}
Ok(())
}
fn copy_dir_recursive(source: &Path, target: &Path) -> io::Result<()> {
@@ -395,28 +437,11 @@ fn destination_is_same_or_descendant_of_source(
destination: &Path,
) -> io::Result<bool> {
let source = std::fs::canonicalize(source)?;
let destination = resolve_path_for_access_check(destination, AccessPathMode::ResolveAll)?;
let destination = resolve_existing_path(destination)?;
Ok(destination.starts_with(&source))
}
fn resolve_path_for_access_check(path: &Path, path_mode: AccessPathMode) -> io::Result<PathBuf> {
match path_mode {
AccessPathMode::ResolveAll => resolve_existing_path(path),
AccessPathMode::PreserveLeaf => preserve_leaf_path_for_access_check(path),
}
}
fn preserve_leaf_path_for_access_check(path: &Path) -> io::Result<PathBuf> {
let Some(file_name) = path.file_name() else {
return resolve_existing_path(path);
};
let parent = path.parent().unwrap_or_else(|| Path::new("/"));
let mut resolved_parent = resolve_existing_path(parent)?;
resolved_parent.push(file_name);
Ok(resolved_parent)
}
fn resolve_existing_path(path: &Path) -> io::Result<PathBuf> {
pub(crate) fn resolve_existing_path(path: &Path) -> io::Result<PathBuf> {
let mut unresolved_suffix = Vec::new();
let mut existing_path = path;
while !existing_path.exists() {
@@ -437,33 +462,12 @@ fn resolve_existing_path(path: &Path) -> io::Result<PathBuf> {
Ok(resolved)
}
fn current_sandbox_cwd() -> io::Result<PathBuf> {
pub(crate) fn current_sandbox_cwd() -> io::Result<PathBuf> {
let cwd = std::env::current_dir()
.map_err(|err| io::Error::other(format!("failed to read current dir: {err}")))?;
resolve_existing_path(cwd.as_path())
}
fn canonicalize_file_system_policy_paths(
mut file_system_policy: FileSystemSandboxPolicy,
) -> io::Result<FileSystemSandboxPolicy> {
for entry in &mut file_system_policy.entries {
if let FileSystemPath::Path { path } = &mut entry.path {
*path = canonicalize_absolute_path(path)?;
}
}
Ok(file_system_policy)
}
fn canonicalize_absolute_path(path: &AbsolutePathBuf) -> io::Result<AbsolutePathBuf> {
let resolved = resolve_existing_path(path.as_path())?;
AbsolutePathBuf::from_absolute_path(resolved.as_path()).map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("path must stay absolute after canonicalization: {err}"),
)
})
}
fn copy_symlink(source: &Path, target: &Path) -> io::Result<()> {
let link_target = std::fs::read_link(source)?;
#[cfg(unix)]
@@ -508,29 +512,11 @@ fn system_time_to_unix_ms(time: SystemTime) -> i64 {
#[cfg(all(test, unix))]
mod tests {
use super::*;
use codex_protocol::protocol::ReadOnlyAccess;
use pretty_assertions::assert_eq;
use std::os::unix::fs::symlink;
fn absolute_path(path: PathBuf) -> AbsolutePathBuf {
match AbsolutePathBuf::try_from(path) {
Ok(path) => path,
Err(err) => panic!("absolute path: {err}"),
}
}
fn read_only_sandbox_policy(readable_roots: Vec<PathBuf>) -> SandboxPolicy {
SandboxPolicy::ReadOnly {
access: ReadOnlyAccess::Restricted {
include_platform_defaults: false,
readable_roots: readable_roots.into_iter().map(absolute_path).collect(),
},
network_access: false,
}
}
#[test]
fn resolve_path_for_access_check_rejects_symlink_parent_dotdot_escape() -> io::Result<()> {
fn resolve_existing_path_handles_symlink_parent_dotdot_escape() -> io::Result<()> {
let temp_dir = tempfile::TempDir::new()?;
let allowed_dir = temp_dir.path().join("allowed");
let outside_dir = temp_dir.path().join("outside");
@@ -538,13 +524,12 @@ mod tests {
std::fs::create_dir_all(&outside_dir)?;
symlink(&outside_dir, allowed_dir.join("link"))?;
let resolved = resolve_path_for_access_check(
let resolved = resolve_existing_path(
allowed_dir
.join("link")
.join("..")
.join("secret.txt")
.as_path(),
AccessPathMode::ResolveAll,
)?;
assert_eq!(
@@ -553,29 +538,6 @@ mod tests {
);
Ok(())
}
#[test]
fn enforce_read_access_uses_explicit_sandbox_cwd() -> io::Result<()> {
let temp_dir = tempfile::TempDir::new()?;
let workspace_dir = temp_dir.path().join("workspace");
let other_dir = temp_dir.path().join("other");
let note_path = workspace_dir.join("note.txt");
std::fs::create_dir_all(&workspace_dir)?;
std::fs::create_dir_all(&other_dir)?;
std::fs::write(&note_path, "hello")?;
let sandbox_policy = read_only_sandbox_policy(vec![]);
let sandbox_cwd = absolute_path(workspace_dir);
let other_cwd = absolute_path(other_dir);
let note_path = absolute_path(note_path);
enforce_read_access_for_cwd(&note_path, Some(&sandbox_policy), &sandbox_cwd)?;
let error = enforce_read_access_for_cwd(&note_path, Some(&sandbox_policy), &other_cwd)
.expect_err("read should be rejected outside provided cwd");
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
Ok(())
}
}
#[cfg(all(test, windows))]
+90 -2
View File
@@ -5,6 +5,9 @@ use std::time::Duration;
use async_trait::async_trait;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_config::shell_environment;
use codex_config::types::EnvironmentVariablePattern;
use codex_config::types::ShellEnvironmentPolicy;
use codex_utils_pty::ExecCommandSession;
use codex_utils_pty::TerminalSize;
use tokio::sync::Mutex;
@@ -19,6 +22,7 @@ use crate::ProcessId;
use crate::StartedExecProcess;
use crate::protocol::EXEC_CLOSED_METHOD;
use crate::protocol::ExecClosedNotification;
use crate::protocol::ExecEnvPolicy;
use crate::protocol::ExecExitedNotification;
use crate::protocol::ExecOutputDeltaNotification;
use crate::protocol::ExecOutputStream;
@@ -150,12 +154,13 @@ impl LocalProcess {
process_map.insert(process_id.clone(), ProcessEntry::Starting);
}
let env = child_env(&params);
let spawned_result = if params.tty {
codex_utils_pty::spawn_pty_process(
program,
args,
params.cwd.as_path(),
&params.env,
&env,
&params.arg0,
TerminalSize::default(),
)
@@ -165,7 +170,7 @@ impl LocalProcess {
program,
args,
params.cwd.as_path(),
&params.env,
&env,
&params.arg0,
)
.await
@@ -375,6 +380,36 @@ impl LocalProcess {
}
}
fn child_env(params: &ExecParams) -> HashMap<String, String> {
let Some(env_policy) = &params.env_policy else {
return params.env.clone();
};
let policy = shell_environment_policy(env_policy);
let mut env = shell_environment::create_env(&policy, /*thread_id*/ None);
env.extend(params.env.clone());
env
}
fn shell_environment_policy(env_policy: &ExecEnvPolicy) -> ShellEnvironmentPolicy {
ShellEnvironmentPolicy {
inherit: env_policy.inherit.clone(),
ignore_default_excludes: env_policy.ignore_default_excludes,
exclude: env_policy
.exclude
.iter()
.map(|pattern| EnvironmentVariablePattern::new_case_insensitive(pattern))
.collect(),
r#set: env_policy.r#set.clone(),
include_only: env_policy
.include_only
.iter()
.map(|pattern| EnvironmentVariablePattern::new_case_insensitive(pattern))
.collect(),
use_profile: false,
}
}
#[async_trait]
impl ExecBackend for LocalProcess {
async fn start(&self, params: ExecParams) -> Result<StartedExecProcess, ExecServerError> {
@@ -618,3 +653,56 @@ fn notification_sender(inner: &Inner) -> Option<RpcNotificationSender> {
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
#[cfg(test)]
mod tests {
use super::*;
use codex_config::types::ShellEnvironmentPolicyInherit;
fn test_exec_params(env: HashMap<String, String>) -> ExecParams {
ExecParams {
process_id: ProcessId::from("env-test"),
argv: vec!["true".to_string()],
cwd: std::path::PathBuf::from("/tmp"),
env_policy: None,
env,
tty: false,
arg0: None,
}
}
#[test]
fn child_env_defaults_to_exact_env() {
let params = test_exec_params(HashMap::from([("ONLY_THIS".to_string(), "1".to_string())]));
assert_eq!(
child_env(&params),
HashMap::from([("ONLY_THIS".to_string(), "1".to_string())])
);
}
#[test]
fn child_env_applies_policy_then_overlay() {
let mut params = test_exec_params(HashMap::from([
("OVERLAY".to_string(), "overlay".to_string()),
("POLICY_SET".to_string(), "overlay-wins".to_string()),
]));
params.env_policy = Some(ExecEnvPolicy {
inherit: ShellEnvironmentPolicyInherit::None,
ignore_default_excludes: true,
exclude: Vec::new(),
r#set: HashMap::from([("POLICY_SET".to_string(), "policy".to_string())]),
include_only: Vec::new(),
});
let mut expected = HashMap::from([
("OVERLAY".to_string(), "overlay".to_string()),
("POLICY_SET".to_string(), "overlay-wins".to_string()),
]);
if cfg!(target_os = "windows") {
expected.insert("PATHEXT".to_string(), ".COM;.EXE;.BAT;.CMD".to_string());
}
assert_eq!(child_env(&params), expected);
}
}
+21 -8
View File
@@ -1,8 +1,9 @@
use std::collections::HashMap;
use std::path::PathBuf;
use crate::FileSystemSandboxContext;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_protocol::protocol::SandboxPolicy;
use codex_config::types::ShellEnvironmentPolicyInherit;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde::Serialize;
@@ -64,11 +65,23 @@ pub struct ExecParams {
pub process_id: ProcessId,
pub argv: Vec<String>,
pub cwd: PathBuf,
#[serde(default)]
pub env_policy: Option<ExecEnvPolicy>,
pub env: HashMap<String, String>,
pub tty: bool,
pub arg0: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExecEnvPolicy {
pub inherit: ShellEnvironmentPolicyInherit,
pub ignore_default_excludes: bool,
pub exclude: Vec<String>,
pub r#set: HashMap<String, String>,
pub include_only: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExecResponse {
@@ -141,7 +154,7 @@ pub struct TerminateResponse {
#[serde(rename_all = "camelCase")]
pub struct FsReadFileParams {
pub path: AbsolutePathBuf,
pub sandbox_policy: Option<SandboxPolicy>,
pub sandbox: Option<FileSystemSandboxContext>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -155,7 +168,7 @@ pub struct FsReadFileResponse {
pub struct FsWriteFileParams {
pub path: AbsolutePathBuf,
pub data_base64: String,
pub sandbox_policy: Option<SandboxPolicy>,
pub sandbox: Option<FileSystemSandboxContext>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -167,7 +180,7 @@ pub struct FsWriteFileResponse {}
pub struct FsCreateDirectoryParams {
pub path: AbsolutePathBuf,
pub recursive: Option<bool>,
pub sandbox_policy: Option<SandboxPolicy>,
pub sandbox: Option<FileSystemSandboxContext>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -178,7 +191,7 @@ pub struct FsCreateDirectoryResponse {}
#[serde(rename_all = "camelCase")]
pub struct FsGetMetadataParams {
pub path: AbsolutePathBuf,
pub sandbox_policy: Option<SandboxPolicy>,
pub sandbox: Option<FileSystemSandboxContext>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -194,7 +207,7 @@ pub struct FsGetMetadataResponse {
#[serde(rename_all = "camelCase")]
pub struct FsReadDirectoryParams {
pub path: AbsolutePathBuf,
pub sandbox_policy: Option<SandboxPolicy>,
pub sandbox: Option<FileSystemSandboxContext>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -217,7 +230,7 @@ pub struct FsRemoveParams {
pub path: AbsolutePathBuf,
pub recursive: Option<bool>,
pub force: Option<bool>,
pub sandbox_policy: Option<SandboxPolicy>,
pub sandbox: Option<FileSystemSandboxContext>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -230,7 +243,7 @@ pub struct FsCopyParams {
pub source_path: AbsolutePathBuf,
pub destination_path: AbsolutePathBuf,
pub recursive: bool,
pub sandbox_policy: Option<SandboxPolicy>,
pub sandbox: Option<FileSystemSandboxContext>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+27 -150
View File
@@ -1,7 +1,6 @@
use async_trait::async_trait;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use tokio::io;
use tracing::trace;
@@ -13,6 +12,7 @@ use crate::ExecServerError;
use crate::ExecutorFileSystem;
use crate::FileMetadata;
use crate::FileSystemResult;
use crate::FileSystemSandboxContext;
use crate::ReadDirectoryEntry;
use crate::RemoveOptions;
use crate::protocol::FsCopyParams;
@@ -40,13 +40,17 @@ impl RemoteFileSystem {
#[async_trait]
impl ExecutorFileSystem for RemoteFileSystem {
async fn read_file(&self, path: &AbsolutePathBuf) -> FileSystemResult<Vec<u8>> {
async fn read_file(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<Vec<u8>> {
trace!("remote fs read_file");
let response = self
.client
.fs_read_file(FsReadFileParams {
path: path.clone(),
sandbox_policy: None,
sandbox: sandbox.cloned(),
})
.await
.map_err(map_remote_error)?;
@@ -58,53 +62,18 @@ impl ExecutorFileSystem for RemoteFileSystem {
})
}
async fn read_file_with_sandbox_policy(
async fn write_file(
&self,
path: &AbsolutePathBuf,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<Vec<u8>> {
trace!("remote fs read_file_with_sandbox_policy");
let response = self
.client
.fs_read_file(FsReadFileParams {
path: path.clone(),
sandbox_policy: sandbox_policy.cloned(),
})
.await
.map_err(map_remote_error)?;
STANDARD.decode(response.data_base64).map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("remote fs/readFile returned invalid base64 dataBase64: {err}"),
)
})
}
async fn write_file(&self, path: &AbsolutePathBuf, contents: Vec<u8>) -> FileSystemResult<()> {
contents: Vec<u8>,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
trace!("remote fs write_file");
self.client
.fs_write_file(FsWriteFileParams {
path: path.clone(),
data_base64: STANDARD.encode(contents),
sandbox_policy: None,
})
.await
.map_err(map_remote_error)?;
Ok(())
}
async fn write_file_with_sandbox_policy(
&self,
path: &AbsolutePathBuf,
contents: Vec<u8>,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<()> {
trace!("remote fs write_file_with_sandbox_policy");
self.client
.fs_write_file(FsWriteFileParams {
path: path.clone(),
data_base64: STANDARD.encode(contents),
sandbox_policy: sandbox_policy.cloned(),
sandbox: sandbox.cloned(),
})
.await
.map_err(map_remote_error)?;
@@ -115,66 +84,31 @@ impl ExecutorFileSystem for RemoteFileSystem {
&self,
path: &AbsolutePathBuf,
options: CreateDirectoryOptions,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
trace!("remote fs create_directory");
self.client
.fs_create_directory(FsCreateDirectoryParams {
path: path.clone(),
recursive: Some(options.recursive),
sandbox_policy: None,
sandbox: sandbox.cloned(),
})
.await
.map_err(map_remote_error)?;
Ok(())
}
async fn create_directory_with_sandbox_policy(
async fn get_metadata(
&self,
path: &AbsolutePathBuf,
create_directory_options: CreateDirectoryOptions,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<()> {
trace!("remote fs create_directory_with_sandbox_policy");
self.client
.fs_create_directory(FsCreateDirectoryParams {
path: path.clone(),
recursive: Some(create_directory_options.recursive),
sandbox_policy: sandbox_policy.cloned(),
})
.await
.map_err(map_remote_error)?;
Ok(())
}
async fn get_metadata(&self, path: &AbsolutePathBuf) -> FileSystemResult<FileMetadata> {
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<FileMetadata> {
trace!("remote fs get_metadata");
let response = self
.client
.fs_get_metadata(FsGetMetadataParams {
path: path.clone(),
sandbox_policy: None,
})
.await
.map_err(map_remote_error)?;
Ok(FileMetadata {
is_directory: response.is_directory,
is_file: response.is_file,
created_at_ms: response.created_at_ms,
modified_at_ms: response.modified_at_ms,
})
}
async fn get_metadata_with_sandbox_policy(
&self,
path: &AbsolutePathBuf,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<FileMetadata> {
trace!("remote fs get_metadata_with_sandbox_policy");
let response = self
.client
.fs_get_metadata(FsGetMetadataParams {
path: path.clone(),
sandbox_policy: sandbox_policy.cloned(),
sandbox: sandbox.cloned(),
})
.await
.map_err(map_remote_error)?;
@@ -189,13 +123,14 @@ impl ExecutorFileSystem for RemoteFileSystem {
async fn read_directory(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<Vec<ReadDirectoryEntry>> {
trace!("remote fs read_directory");
let response = self
.client
.fs_read_directory(FsReadDirectoryParams {
path: path.clone(),
sandbox_policy: None,
sandbox: sandbox.cloned(),
})
.await
.map_err(map_remote_error)?;
@@ -210,58 +145,19 @@ impl ExecutorFileSystem for RemoteFileSystem {
.collect())
}
async fn read_directory_with_sandbox_policy(
async fn remove(
&self,
path: &AbsolutePathBuf,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<Vec<ReadDirectoryEntry>> {
trace!("remote fs read_directory_with_sandbox_policy");
let response = self
.client
.fs_read_directory(FsReadDirectoryParams {
path: path.clone(),
sandbox_policy: sandbox_policy.cloned(),
})
.await
.map_err(map_remote_error)?;
Ok(response
.entries
.into_iter()
.map(|entry| ReadDirectoryEntry {
file_name: entry.file_name,
is_directory: entry.is_directory,
is_file: entry.is_file,
})
.collect())
}
async fn remove(&self, path: &AbsolutePathBuf, options: RemoveOptions) -> FileSystemResult<()> {
options: RemoveOptions,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
trace!("remote fs remove");
self.client
.fs_remove(FsRemoveParams {
path: path.clone(),
recursive: Some(options.recursive),
force: Some(options.force),
sandbox_policy: None,
})
.await
.map_err(map_remote_error)?;
Ok(())
}
async fn remove_with_sandbox_policy(
&self,
path: &AbsolutePathBuf,
remove_options: RemoveOptions,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<()> {
trace!("remote fs remove_with_sandbox_policy");
self.client
.fs_remove(FsRemoveParams {
path: path.clone(),
recursive: Some(remove_options.recursive),
force: Some(remove_options.force),
sandbox_policy: sandbox_policy.cloned(),
sandbox: sandbox.cloned(),
})
.await
.map_err(map_remote_error)?;
@@ -273,6 +169,7 @@ impl ExecutorFileSystem for RemoteFileSystem {
source_path: &AbsolutePathBuf,
destination_path: &AbsolutePathBuf,
options: CopyOptions,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
trace!("remote fs copy");
self.client
@@ -280,27 +177,7 @@ impl ExecutorFileSystem for RemoteFileSystem {
source_path: source_path.clone(),
destination_path: destination_path.clone(),
recursive: options.recursive,
sandbox_policy: None,
})
.await
.map_err(map_remote_error)?;
Ok(())
}
async fn copy_with_sandbox_policy(
&self,
source_path: &AbsolutePathBuf,
destination_path: &AbsolutePathBuf,
copy_options: CopyOptions,
sandbox_policy: Option<&SandboxPolicy>,
) -> FileSystemResult<()> {
trace!("remote fs copy_with_sandbox_policy");
self.client
.fs_copy(FsCopyParams {
source_path: source_path.clone(),
destination_path: destination_path.clone(),
recursive: copy_options.recursive,
sandbox_policy: sandbox_policy.cloned(),
sandbox: sandbox.cloned(),
})
.await
.map_err(map_remote_error)?;
+43
View File
@@ -0,0 +1,43 @@
use std::path::PathBuf;
use codex_utils_absolute_path::AbsolutePathBuf;
/// Runtime paths needed by exec-server child processes.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ExecServerRuntimePaths {
/// Stable path to the Codex executable used to launch hidden helper modes.
pub codex_self_exe: AbsolutePathBuf,
/// Path to the Linux sandbox helper alias used when the platform sandbox
/// needs to re-enter Codex by argv0.
pub codex_linux_sandbox_exe: Option<AbsolutePathBuf>,
}
impl ExecServerRuntimePaths {
pub fn from_optional_paths(
codex_self_exe: Option<PathBuf>,
codex_linux_sandbox_exe: Option<PathBuf>,
) -> std::io::Result<Self> {
let codex_self_exe = codex_self_exe.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"Codex executable path is not configured",
)
})?;
Self::new(codex_self_exe, codex_linux_sandbox_exe)
}
pub fn new(
codex_self_exe: PathBuf,
codex_linux_sandbox_exe: Option<PathBuf>,
) -> std::io::Result<Self> {
Ok(Self {
codex_self_exe: absolute_path(codex_self_exe)?,
codex_linux_sandbox_exe: codex_linux_sandbox_exe.map(absolute_path).transpose()?,
})
}
}
fn absolute_path(path: PathBuf) -> std::io::Result<AbsolutePathBuf> {
AbsolutePathBuf::from_absolute_path(path.as_path())
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))
}
@@ -0,0 +1,239 @@
use async_trait::async_trait;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_utils_absolute_path::AbsolutePathBuf;
use tokio::io;
use crate::CopyOptions;
use crate::CreateDirectoryOptions;
use crate::ExecServerRuntimePaths;
use crate::ExecutorFileSystem;
use crate::FileMetadata;
use crate::FileSystemResult;
use crate::FileSystemSandboxContext;
use crate::ReadDirectoryEntry;
use crate::RemoveOptions;
use crate::fs_helper::FsHelperPayload;
use crate::fs_helper::FsHelperRequest;
use crate::fs_sandbox::FileSystemSandboxRunner;
use crate::protocol::FsCopyParams;
use crate::protocol::FsCreateDirectoryParams;
use crate::protocol::FsGetMetadataParams;
use crate::protocol::FsReadDirectoryParams;
use crate::protocol::FsReadFileParams;
use crate::protocol::FsRemoveParams;
use crate::protocol::FsWriteFileParams;
#[derive(Clone)]
pub struct SandboxedFileSystem {
sandbox_runner: FileSystemSandboxRunner,
}
impl SandboxedFileSystem {
pub fn new(runtime_paths: ExecServerRuntimePaths) -> Self {
Self {
sandbox_runner: FileSystemSandboxRunner::new(runtime_paths),
}
}
async fn run_sandboxed(
&self,
sandbox: &FileSystemSandboxContext,
request: FsHelperRequest,
) -> FileSystemResult<FsHelperPayload> {
self.sandbox_runner
.run(sandbox, request)
.await
.map_err(map_sandbox_error)
}
}
#[async_trait]
impl ExecutorFileSystem for SandboxedFileSystem {
async fn read_file(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<Vec<u8>> {
let sandbox = require_platform_sandbox(sandbox)?;
let response = self
.run_sandboxed(
sandbox,
FsHelperRequest::ReadFile(FsReadFileParams {
path: path.clone(),
sandbox: None,
}),
)
.await?
.expect_read_file()
.map_err(map_sandbox_error)?;
STANDARD.decode(response.data_base64).map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("fs/readFile returned invalid base64 dataBase64: {err}"),
)
})
}
async fn write_file(
&self,
path: &AbsolutePathBuf,
contents: Vec<u8>,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
let sandbox = require_platform_sandbox(sandbox)?;
self.run_sandboxed(
sandbox,
FsHelperRequest::WriteFile(FsWriteFileParams {
path: path.clone(),
data_base64: STANDARD.encode(contents),
sandbox: None,
}),
)
.await?
.expect_write_file()
.map_err(map_sandbox_error)?;
Ok(())
}
async fn create_directory(
&self,
path: &AbsolutePathBuf,
options: CreateDirectoryOptions,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
let sandbox = require_platform_sandbox(sandbox)?;
self.run_sandboxed(
sandbox,
FsHelperRequest::CreateDirectory(FsCreateDirectoryParams {
path: path.clone(),
recursive: Some(options.recursive),
sandbox: None,
}),
)
.await?
.expect_create_directory()
.map_err(map_sandbox_error)?;
Ok(())
}
async fn get_metadata(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<FileMetadata> {
let sandbox = require_platform_sandbox(sandbox)?;
let response = self
.run_sandboxed(
sandbox,
FsHelperRequest::GetMetadata(FsGetMetadataParams {
path: path.clone(),
sandbox: None,
}),
)
.await?
.expect_get_metadata()
.map_err(map_sandbox_error)?;
Ok(FileMetadata {
is_directory: response.is_directory,
is_file: response.is_file,
created_at_ms: response.created_at_ms,
modified_at_ms: response.modified_at_ms,
})
}
async fn read_directory(
&self,
path: &AbsolutePathBuf,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<Vec<ReadDirectoryEntry>> {
let sandbox = require_platform_sandbox(sandbox)?;
let response = self
.run_sandboxed(
sandbox,
FsHelperRequest::ReadDirectory(FsReadDirectoryParams {
path: path.clone(),
sandbox: None,
}),
)
.await?
.expect_read_directory()
.map_err(map_sandbox_error)?;
Ok(response
.entries
.into_iter()
.map(|entry| ReadDirectoryEntry {
file_name: entry.file_name,
is_directory: entry.is_directory,
is_file: entry.is_file,
})
.collect())
}
async fn remove(
&self,
path: &AbsolutePathBuf,
remove_options: RemoveOptions,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
let sandbox = require_platform_sandbox(sandbox)?;
self.run_sandboxed(
sandbox,
FsHelperRequest::Remove(FsRemoveParams {
path: path.clone(),
recursive: Some(remove_options.recursive),
force: Some(remove_options.force),
sandbox: None,
}),
)
.await?
.expect_remove()
.map_err(map_sandbox_error)?;
Ok(())
}
async fn copy(
&self,
source_path: &AbsolutePathBuf,
destination_path: &AbsolutePathBuf,
options: CopyOptions,
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<()> {
let sandbox = require_platform_sandbox(sandbox)?;
self.run_sandboxed(
sandbox,
FsHelperRequest::Copy(FsCopyParams {
source_path: source_path.clone(),
destination_path: destination_path.clone(),
recursive: options.recursive,
sandbox: None,
}),
)
.await?
.expect_copy()
.map_err(map_sandbox_error)?;
Ok(())
}
}
fn require_platform_sandbox(
sandbox: Option<&FileSystemSandboxContext>,
) -> FileSystemResult<&FileSystemSandboxContext> {
sandbox
.filter(|sandbox| sandbox.should_run_in_sandbox())
.ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"sandboxed filesystem operations require ReadOnly or WorkspaceWrite sandbox policy",
)
})
}
fn map_sandbox_error(error: JSONRPCErrorError) -> io::Error {
match error.code {
-32004 => io::Error::new(io::ErrorKind::NotFound, error.message),
-32600 => io::Error::new(io::ErrorKind::InvalidInput, error.message),
_ => io::Error::other(error.message),
}
}
+4 -5
View File
@@ -10,12 +10,11 @@ pub(crate) use handler::ExecServerHandler;
pub use transport::DEFAULT_LISTEN_URL;
pub use transport::ExecServerListenUrlParseError;
pub async fn run_main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
run_main_with_listen_url(DEFAULT_LISTEN_URL).await
}
use crate::ExecServerRuntimePaths;
pub async fn run_main_with_listen_url(
pub async fn run_main(
listen_url: &str,
runtime_paths: ExecServerRuntimePaths,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
transport::run_transport(listen_url).await
transport::run_transport(listen_url, runtime_paths).await
}
@@ -6,9 +6,11 @@ use codex_app_server_protocol::JSONRPCErrorError;
use crate::CopyOptions;
use crate::CreateDirectoryOptions;
use crate::ExecServerRuntimePaths;
use crate::ExecutorFileSystem;
use crate::RemoveOptions;
use crate::local_file_system::LocalFileSystem;
use crate::protocol::FS_WRITE_FILE_METHOD;
use crate::protocol::FsCopyParams;
use crate::protocol::FsCopyResponse;
use crate::protocol::FsCreateDirectoryParams;
@@ -28,19 +30,25 @@ use crate::rpc::internal_error;
use crate::rpc::invalid_request;
use crate::rpc::not_found;
#[derive(Clone, Default)]
#[derive(Clone)]
pub(crate) struct FileSystemHandler {
file_system: LocalFileSystem,
}
impl FileSystemHandler {
pub(crate) fn new(runtime_paths: ExecServerRuntimePaths) -> Self {
Self {
file_system: LocalFileSystem::with_runtime_paths(runtime_paths),
}
}
pub(crate) async fn read_file(
&self,
params: FsReadFileParams,
) -> Result<FsReadFileResponse, JSONRPCErrorError> {
let bytes = self
.file_system
.read_file_with_sandbox_policy(&params.path, params.sandbox_policy.as_ref())
.read_file(&params.path, params.sandbox.as_ref())
.await
.map_err(map_fs_error)?;
Ok(FsReadFileResponse {
@@ -54,11 +62,11 @@ impl FileSystemHandler {
) -> Result<FsWriteFileResponse, JSONRPCErrorError> {
let bytes = STANDARD.decode(params.data_base64).map_err(|err| {
invalid_request(format!(
"fs/writeFile requires valid base64 dataBase64: {err}"
"{FS_WRITE_FILE_METHOD} requires valid base64 dataBase64: {err}"
))
})?;
self.file_system
.write_file_with_sandbox_policy(&params.path, bytes, params.sandbox_policy.as_ref())
.write_file(&params.path, bytes, params.sandbox.as_ref())
.await
.map_err(map_fs_error)?;
Ok(FsWriteFileResponse {})
@@ -68,13 +76,12 @@ impl FileSystemHandler {
&self,
params: FsCreateDirectoryParams,
) -> Result<FsCreateDirectoryResponse, JSONRPCErrorError> {
let recursive = params.recursive.unwrap_or(true);
self.file_system
.create_directory_with_sandbox_policy(
.create_directory(
&params.path,
CreateDirectoryOptions {
recursive: params.recursive.unwrap_or(true),
},
params.sandbox_policy.as_ref(),
CreateDirectoryOptions { recursive },
params.sandbox.as_ref(),
)
.await
.map_err(map_fs_error)?;
@@ -87,7 +94,7 @@ impl FileSystemHandler {
) -> Result<FsGetMetadataResponse, JSONRPCErrorError> {
let metadata = self
.file_system
.get_metadata_with_sandbox_policy(&params.path, params.sandbox_policy.as_ref())
.get_metadata(&params.path, params.sandbox.as_ref())
.await
.map_err(map_fs_error)?;
Ok(FsGetMetadataResponse {
@@ -104,33 +111,30 @@ impl FileSystemHandler {
) -> Result<FsReadDirectoryResponse, JSONRPCErrorError> {
let entries = self
.file_system
.read_directory_with_sandbox_policy(&params.path, params.sandbox_policy.as_ref())
.read_directory(&params.path, params.sandbox.as_ref())
.await
.map_err(map_fs_error)?;
Ok(FsReadDirectoryResponse {
entries: entries
.into_iter()
.map(|entry| FsReadDirectoryEntry {
file_name: entry.file_name,
is_directory: entry.is_directory,
is_file: entry.is_file,
})
.collect(),
})
.map_err(map_fs_error)?
.into_iter()
.map(|entry| FsReadDirectoryEntry {
file_name: entry.file_name,
is_directory: entry.is_directory,
is_file: entry.is_file,
})
.collect();
Ok(FsReadDirectoryResponse { entries })
}
pub(crate) async fn remove(
&self,
params: FsRemoveParams,
) -> Result<FsRemoveResponse, JSONRPCErrorError> {
let recursive = params.recursive.unwrap_or(true);
let force = params.force.unwrap_or(true);
self.file_system
.remove_with_sandbox_policy(
.remove(
&params.path,
RemoveOptions {
recursive: params.recursive.unwrap_or(true),
force: params.force.unwrap_or(true),
},
params.sandbox_policy.as_ref(),
RemoveOptions { recursive, force },
params.sandbox.as_ref(),
)
.await
.map_err(map_fs_error)?;
@@ -142,13 +146,13 @@ impl FileSystemHandler {
params: FsCopyParams,
) -> Result<FsCopyResponse, JSONRPCErrorError> {
self.file_system
.copy_with_sandbox_policy(
.copy(
&params.source_path,
&params.destination_path,
CopyOptions {
recursive: params.recursive,
},
params.sandbox_policy.as_ref(),
params.sandbox.as_ref(),
)
.await
.map_err(map_fs_error)?;
@@ -157,11 +161,68 @@ impl FileSystemHandler {
}
fn map_fs_error(err: io::Error) -> JSONRPCErrorError {
if err.kind() == io::ErrorKind::NotFound {
not_found(err.to_string())
} else if err.kind() == io::ErrorKind::InvalidInput {
invalid_request(err.to_string())
} else {
internal_error(err.to_string())
match err.kind() {
io::ErrorKind::NotFound => not_found(err.to_string()),
io::ErrorKind::InvalidInput | io::ErrorKind::PermissionDenied => {
invalid_request(err.to_string())
}
_ => internal_error(err.to_string()),
}
}
#[cfg(test)]
mod tests {
use codex_protocol::protocol::NetworkAccess;
use codex_protocol::protocol::SandboxPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use super::*;
use crate::FileSystemSandboxContext;
use crate::protocol::FsReadFileParams;
use crate::protocol::FsWriteFileParams;
#[tokio::test]
async fn no_platform_sandbox_policies_do_not_require_configured_sandbox_helper() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let runtime_paths = ExecServerRuntimePaths::new(
std::env::current_exe().expect("current exe"),
/*codex_linux_sandbox_exe*/ None,
)
.expect("runtime paths");
let handler = FileSystemHandler::new(runtime_paths);
for (file_name, sandbox_policy) in [
("danger.txt", SandboxPolicy::DangerFullAccess),
(
"external.txt",
SandboxPolicy::ExternalSandbox {
network_access: NetworkAccess::Restricted,
},
),
] {
let path =
AbsolutePathBuf::from_absolute_path(temp_dir.path().join(file_name).as_path())
.expect("absolute path");
handler
.write_file(FsWriteFileParams {
path: path.clone(),
data_base64: STANDARD.encode("ok"),
sandbox: Some(FileSystemSandboxContext::new(sandbox_policy.clone())),
})
.await
.expect("write file");
let response = handler
.read_file(FsReadFileParams {
path,
sandbox: Some(FileSystemSandboxContext::new(sandbox_policy)),
})
.await
.expect("read file");
assert_eq!(response.data_base64, STANDARD.encode("ok"));
}
}
}
+3 -1
View File
@@ -5,6 +5,7 @@ use std::sync::atomic::Ordering;
use codex_app_server_protocol::JSONRPCErrorError;
use crate::ExecServerRuntimePaths;
use crate::protocol::ExecParams;
use crate::protocol::ExecResponse;
use crate::protocol::FsCopyParams;
@@ -48,12 +49,13 @@ impl ExecServerHandler {
pub(crate) fn new(
session_registry: Arc<SessionRegistry>,
notifications: RpcNotificationSender,
runtime_paths: ExecServerRuntimePaths,
) -> Self {
Self {
session_registry,
notifications,
session: StdMutex::new(None),
file_system: FileSystemHandler::default(),
file_system: FileSystemHandler::new(runtime_paths),
initialize_requested: AtomicBool::new(false),
initialized: AtomicBool::new(false),
}
@@ -7,6 +7,7 @@ use tokio::sync::mpsc;
use uuid::Uuid;
use super::ExecServerHandler;
use crate::ExecServerRuntimePaths;
use crate::ProcessId;
use crate::protocol::ExecParams;
use crate::protocol::InitializeParams;
@@ -26,6 +27,7 @@ fn exec_params_with_argv(process_id: &str, argv: Vec<String>) -> ExecParams {
process_id: ProcessId::from(process_id),
argv,
cwd: std::env::current_dir().expect("cwd"),
env_policy: None,
env: inherited_path_env(),
tty: false,
arg0: None,
@@ -64,12 +66,21 @@ fn windows_command_processor() -> String {
std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string())
}
fn test_runtime_paths() -> ExecServerRuntimePaths {
ExecServerRuntimePaths::new(
std::env::current_exe().expect("current exe"),
/*codex_linux_sandbox_exe*/ None,
)
.expect("runtime paths")
}
async fn initialized_handler() -> Arc<ExecServerHandler> {
let (outgoing_tx, _outgoing_rx) = mpsc::channel(16);
let registry = SessionRegistry::new();
let handler = Arc::new(ExecServerHandler::new(
registry,
RpcNotificationSender::new(outgoing_tx),
test_runtime_paths(),
));
let initialize_response = handler
.initialize(InitializeParams {
@@ -147,6 +158,7 @@ async fn long_poll_read_fails_after_session_resume() {
let first_handler = Arc::new(ExecServerHandler::new(
Arc::clone(&registry),
RpcNotificationSender::new(first_tx),
test_runtime_paths(),
));
let initialize_response = first_handler
.initialize(InitializeParams {
@@ -187,6 +199,7 @@ async fn long_poll_read_fails_after_session_resume() {
let second_handler = Arc::new(ExecServerHandler::new(
registry,
RpcNotificationSender::new(second_tx),
test_runtime_paths(),
));
second_handler
.initialize(InitializeParams {
@@ -219,6 +232,7 @@ async fn active_session_resume_is_rejected() {
let first_handler = Arc::new(ExecServerHandler::new(
Arc::clone(&registry),
RpcNotificationSender::new(first_tx),
test_runtime_paths(),
));
let initialize_response = first_handler
.initialize(InitializeParams {
@@ -232,6 +246,7 @@ async fn active_session_resume_is_rejected() {
let second_handler = Arc::new(ExecServerHandler::new(
registry,
RpcNotificationSender::new(second_tx),
test_runtime_paths(),
));
let err = second_handler
.initialize(InitializeParams {
@@ -259,6 +274,7 @@ async fn output_and_exit_are_retained_after_notification_receiver_closes() {
let handler = Arc::new(ExecServerHandler::new(
SessionRegistry::new(),
RpcNotificationSender::new(outgoing_tx),
test_runtime_paths(),
));
handler
.initialize(InitializeParams {
+31 -5
View File
@@ -4,6 +4,7 @@ use tokio::sync::mpsc;
use tracing::debug;
use tracing::warn;
use crate::ExecServerRuntimePaths;
use crate::connection::CHANNEL_CAPACITY;
use crate::connection::JsonRpcConnection;
use crate::connection::JsonRpcConnectionEvent;
@@ -19,28 +20,43 @@ use crate::server::session_registry::SessionRegistry;
#[derive(Clone)]
pub(crate) struct ConnectionProcessor {
session_registry: Arc<SessionRegistry>,
runtime_paths: ExecServerRuntimePaths,
}
impl ConnectionProcessor {
pub(crate) fn new() -> Self {
pub(crate) fn new(runtime_paths: ExecServerRuntimePaths) -> Self {
Self {
session_registry: SessionRegistry::new(),
runtime_paths,
}
}
pub(crate) async fn run_connection(&self, connection: JsonRpcConnection) {
run_connection(connection, Arc::clone(&self.session_registry)).await;
run_connection(
connection,
Arc::clone(&self.session_registry),
self.runtime_paths.clone(),
)
.await;
}
}
async fn run_connection(connection: JsonRpcConnection, session_registry: Arc<SessionRegistry>) {
async fn run_connection(
connection: JsonRpcConnection,
session_registry: Arc<SessionRegistry>,
runtime_paths: ExecServerRuntimePaths,
) {
let router = Arc::new(build_router());
let (json_outgoing_tx, mut incoming_rx, mut disconnected_rx, connection_tasks) =
connection.into_parts();
let (outgoing_tx, mut outgoing_rx) =
mpsc::channel::<RpcServerOutboundMessage>(CHANNEL_CAPACITY);
let notifications = RpcNotificationSender::new(outgoing_tx.clone());
let handler = Arc::new(ExecServerHandler::new(session_registry, notifications));
let handler = Arc::new(ExecServerHandler::new(
session_registry,
notifications,
runtime_paths,
));
let outbound_task = tokio::spawn(async move {
while let Some(message) = outgoing_rx.recv().await {
@@ -184,6 +200,7 @@ mod tests {
use tokio::time::timeout;
use super::run_connection;
use crate::ExecServerRuntimePaths;
use crate::ProcessId;
use crate::connection::JsonRpcConnection;
use crate::protocol::EXEC_METHOD;
@@ -298,10 +315,18 @@ mod tests {
let (server_writer, client_reader) = duplex(1 << 20);
let connection =
JsonRpcConnection::from_stdio(server_reader, server_writer, label.to_string());
let task = tokio::spawn(run_connection(connection, registry));
let task = tokio::spawn(run_connection(connection, registry, test_runtime_paths()));
(client_writer, BufReader::new(client_reader).lines(), task)
}
fn test_runtime_paths() -> ExecServerRuntimePaths {
ExecServerRuntimePaths::new(
std::env::current_exe().expect("current exe"),
/*codex_linux_sandbox_exe*/ None,
)
.expect("runtime paths")
}
async fn send_request<P: Serialize>(
writer: &mut DuplexStream,
id: i64,
@@ -365,6 +390,7 @@ mod tests {
process_id,
argv: sleep_then_print_argv(),
cwd: std::env::current_dir().expect("cwd"),
env_policy: None,
env,
tty: false,
arg0: None,
+7 -3
View File
@@ -1,9 +1,10 @@
use std::io::Write as _;
use std::net::SocketAddr;
use tokio::net::TcpListener;
use tokio_tungstenite::accept_async;
use tracing::warn;
use crate::ExecServerRuntimePaths;
use crate::connection::JsonRpcConnection;
use crate::server::processor::ConnectionProcessor;
@@ -48,19 +49,22 @@ pub(crate) fn parse_listen_url(
pub(crate) async fn run_transport(
listen_url: &str,
runtime_paths: ExecServerRuntimePaths,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let bind_address = parse_listen_url(listen_url)?;
run_websocket_listener(bind_address).await
run_websocket_listener(bind_address, runtime_paths).await
}
async fn run_websocket_listener(
bind_address: SocketAddr,
runtime_paths: ExecServerRuntimePaths,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let listener = TcpListener::bind(bind_address).await?;
let local_addr = listener.local_addr()?;
let processor = ConnectionProcessor::new();
let processor = ConnectionProcessor::new(runtime_paths);
tracing::info!("codex-exec-server listening on ws://{local_addr}");
println!("ws://{local_addr}");
std::io::stdout().flush()?;
loop {
let (stream, peer_addr) = listener.accept().await?;
@@ -41,12 +41,13 @@ impl Drop for ExecServerHarness {
}
pub(crate) async fn exec_server() -> anyhow::Result<ExecServerHarness> {
let binary = cargo_bin("codex-exec-server")?;
let binary = cargo_bin("codex")?;
let mut child = Command::new(binary);
child.args(["--listen", "ws://127.0.0.1:0"]);
child.args(["exec-server", "--listen", "ws://127.0.0.1:0"]);
child.stdin(Stdio::null());
child.stdout(Stdio::piped());
child.stderr(Stdio::inherit());
child.kill_on_drop(true);
let mut child = child.spawn()?;
let websocket_url = read_listen_url_from_stdout(&mut child).await?;
@@ -140,6 +141,9 @@ impl ExecServerHarness {
pub(crate) async fn shutdown(&mut self) -> anyhow::Result<()> {
self.child.start_kill()?;
timeout(CONNECT_TIMEOUT, self.child.wait())
.await
.map_err(|_| anyhow!("timed out waiting for exec-server shutdown"))??;
Ok(())
}

Some files were not shown because too many files have changed in this diff Show More