app-server: accept environment-native cwd

This commit is contained in:
Adam Perry
2026-06-16 16:13:43 +00:00
parent 76135cbe7e
commit 77cb186612
16 changed files with 261 additions and 64 deletions
+9 -1
View File
@@ -78,6 +78,9 @@
}
]
},
"ApiPathString": {
"type": "string"
},
"ApprovalsReviewer": {
"description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.",
"enum": [
@@ -4331,7 +4334,12 @@
"TurnEnvironmentParams": {
"properties": {
"cwd": {
"$ref": "#/definitions/AbsolutePathBuf"
"allOf": [
{
"$ref": "#/definitions/ApiPathString"
}
],
"description": "Absolute cwd rendered using the selected environment's path convention."
},
"environmentId": {
"type": "string"
@@ -19517,7 +19517,12 @@
"TurnEnvironmentParams": {
"properties": {
"cwd": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
"allOf": [
{
"$ref": "#/definitions/v2/ApiPathString"
}
],
"description": "Absolute cwd rendered using the selected environment's path convention."
},
"environmentId": {
"type": "string"
@@ -17325,7 +17325,12 @@
"TurnEnvironmentParams": {
"properties": {
"cwd": {
"$ref": "#/definitions/AbsolutePathBuf"
"allOf": [
{
"$ref": "#/definitions/ApiPathString"
}
],
"description": "Absolute cwd rendered using the selected environment's path convention."
},
"environmentId": {
"type": "string"
@@ -5,6 +5,9 @@
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"ApiPathString": {
"type": "string"
},
"ApprovalsReviewer": {
"description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.",
"enum": [
@@ -242,7 +245,12 @@
"TurnEnvironmentParams": {
"properties": {
"cwd": {
"$ref": "#/definitions/AbsolutePathBuf"
"allOf": [
{
"$ref": "#/definitions/ApiPathString"
}
],
"description": "Absolute cwd rendered using the selected environment's path convention."
},
"environmentId": {
"type": "string"
@@ -27,6 +27,9 @@
],
"type": "string"
},
"ApiPathString": {
"type": "string"
},
"ApprovalsReviewer": {
"description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.",
"enum": [
@@ -331,7 +334,12 @@
"TurnEnvironmentParams": {
"properties": {
"cwd": {
"$ref": "#/definitions/AbsolutePathBuf"
"allOf": [
{
"$ref": "#/definitions/ApiPathString"
}
],
"description": "Absolute cwd rendered using the selected environment's path convention."
},
"environmentId": {
"type": "string"
@@ -1,6 +1,10 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { ApiPathString } from "../ApiPathString";
export type TurnEnvironmentParams = { environmentId: string, cwd: AbsolutePathBuf, };
export type TurnEnvironmentParams = { environmentId: string,
/**
* Absolute cwd rendered using the selected environment's path convention.
*/
cwd: ApiPathString, };
@@ -3723,7 +3723,7 @@ fn thread_settings_update_params_preserve_field_level_experimental_gates() {
#[test]
fn turn_start_params_round_trip_environments() {
let cwd = test_absolute_path();
let cwd = ApiPathString::new(r"C:\workspace");
let params: TurnStartParams = serde_json::from_value(json!({
"threadId": "thread_123",
"input": [],
@@ -3806,8 +3806,8 @@ fn turn_start_params_treat_null_or_omitted_environments_as_default() {
}
#[test]
fn turn_start_params_reject_relative_environment_cwd() {
let err = serde_json::from_value::<TurnStartParams>(json!({
fn turn_start_params_preserve_relative_environment_cwd_for_boundary_validation() {
let params = serde_json::from_value::<TurnStartParams>(json!({
"threadId": "thread_123",
"input": [],
"environments": [
@@ -3817,12 +3817,14 @@ fn turn_start_params_reject_relative_environment_cwd() {
}
],
}))
.expect_err("relative environment cwd should fail");
.expect("native cwd should deserialize without applying host rules");
assert!(
err.to_string()
.contains("AbsolutePathBuf deserialized without a base path"),
"unexpected error: {err}"
assert_eq!(
params.environments,
Some(vec![TurnEnvironmentParams {
environment_id: "local".to_string(),
cwd: ApiPathString::new("relative"),
}])
);
}
@@ -14,6 +14,7 @@ use codex_protocol::user_input::ByteRange as CoreByteRange;
use codex_protocol::user_input::TextElement as CoreTextElement;
use codex_protocol::user_input::UserInput as CoreUserInput;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::ApiPathString;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
@@ -38,7 +39,8 @@ pub enum TurnStatus {
#[ts(export_to = "v2/")]
pub struct TurnEnvironmentParams {
pub environment_id: String,
pub cwd: AbsolutePathBuf,
/// Absolute cwd rendered using the selected environment's path convention.
pub cwd: ApiPathString,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
+1 -1
View File
@@ -709,7 +709,7 @@ Turns attach user input (text or images) to a thread and trigger Codex generatio
- `{"type":"image","url":"https://…png"}`
- `{"type":"localImage","path":"/tmp/screenshot.png"}`
You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread. `outputSchema` applies only to the current turn. Experimental `environments` is turn-scoped: omit it to inherit the thread's sticky environments, pass `[]` to run the turn with no environments, or pass explicit environment ids to override the sticky selection for this turn only.
You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread. `outputSchema` applies only to the current turn. Experimental `environments` is turn-scoped: omit it to inherit the thread's sticky environments, pass `[]` to run the turn with no environments, or pass explicit environment ids to override the sticky selection for this turn only. Each environment `cwd` must be absolute and use that environment's native path convention, so a Windows environment uses a value such as `C:\\workspace` even when app-server runs on Linux.
`approvalsReviewer` accepts:
@@ -541,6 +541,24 @@ fn resolve_request_cwd(cwd: Option<PathBuf>) -> Result<Option<AbsolutePathBuf>,
.transpose()
}
async fn resolve_turn_environment_selections(
thread_manager: &ThreadManager,
environments: Option<Vec<TurnEnvironmentParams>>,
) -> Result<Option<Vec<TurnEnvironmentSelection>>, JSONRPCErrorError> {
let Some(environments) = environments else {
return Ok(None);
};
thread_manager
.resolve_native_environment_selections(
environments
.into_iter()
.map(|environment| (environment.environment_id, environment.cwd)),
)
.await
.map(Some)
.map_err(environment_selection_error)
}
fn resolve_runtime_workspace_roots(workspace_roots: Vec<AbsolutePathBuf>) -> Vec<AbsolutePathBuf> {
let mut resolved_roots = Vec::new();
for root in workspace_roots {
@@ -906,7 +906,8 @@ impl ThreadRequestProcessor {
"`permissions` cannot be combined with `sandbox`",
));
}
let environment_selections = self.parse_environment_selections(environments)?;
let environment_selections =
resolve_turn_environment_selections(self.thread_manager.as_ref(), environments).await?;
let runtime_workspace_roots = runtime_workspace_roots.map(resolve_runtime_workspace_roots);
let mut typesafe_overrides = self.build_thread_config_overrides(
model,
@@ -1311,27 +1312,6 @@ impl ThreadRequestProcessor {
}
}
fn parse_environment_selections(
&self,
environments: Option<Vec<TurnEnvironmentParams>>,
) -> Result<Option<Vec<TurnEnvironmentSelection>>, JSONRPCErrorError> {
let environment_selections = environments.map(|environments| {
environments
.into_iter()
.map(|environment| TurnEnvironmentSelection {
environment_id: environment.environment_id,
cwd: PathUri::from_abs_path(&environment.cwd),
})
.collect::<Vec<_>>()
});
if let Some(environment_selections) = environment_selections.as_ref() {
self.thread_manager
.validate_environment_selections(environment_selections)
.map_err(environment_selection_error)?;
}
Ok(environment_selections)
}
async fn thread_archive_inner(
&self,
params: ThreadArchiveParams,
@@ -341,27 +341,6 @@ impl TurnRequestProcessor {
Ok((review_request, hint))
}
fn parse_environment_selections(
&self,
environments: Option<Vec<TurnEnvironmentParams>>,
) -> Result<Option<Vec<TurnEnvironmentSelection>>, JSONRPCErrorError> {
let environment_selections = environments.map(|environments| {
environments
.into_iter()
.map(|environment| TurnEnvironmentSelection {
environment_id: environment.environment_id,
cwd: PathUri::from_abs_path(&environment.cwd),
})
.collect::<Vec<_>>()
});
if let Some(environment_selections) = environment_selections.as_ref() {
self.thread_manager
.validate_environment_selections(environment_selections)
.map_err(environment_selection_error)?;
}
Ok(environment_selections)
}
async fn request_trace_context(
&self,
request_id: &ConnectionRequestId,
@@ -433,7 +412,9 @@ impl TurnRequestProcessor {
self.track_error_response(&request_id, error, /*error_type*/ None);
})?;
let environment_selections = self.parse_environment_selections(params.environments)?;
let environment_selections =
resolve_turn_environment_selections(self.thread_manager.as_ref(), params.environments)
.await?;
// Map v2 input items to core input items.
let mapped_items: Vec<CoreInputItem> = params
@@ -33,6 +33,7 @@ use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::openai_models::ReasoningEffort;
use codex_utils_path_uri::ApiPathString;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
@@ -293,7 +294,7 @@ async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result
.send_thread_start_request(ThreadStartParams {
environments: Some(vec![TurnEnvironmentParams {
environment_id: "missing".to_string(),
cwd: codex_home.path().to_path_buf().try_into()?,
cwd: ApiPathString::new(codex_home.path().to_string_lossy()),
}]),
..Default::default()
})
@@ -76,6 +76,7 @@ use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS;
use codex_protocol::models::ImageDetail;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
use codex_utils_path_uri::ApiPathString;
use core_test_support::responses;
use core_test_support::skip_if_no_network;
use pretty_assertions::assert_eq;
@@ -1326,7 +1327,7 @@ async fn turn_start_rejects_unknown_environment_before_starting_turn() -> Result
}],
environments: Some(vec![TurnEnvironmentParams {
environment_id: "missing".to_string(),
cwd: codex_home.path().to_path_buf().try_into()?,
cwd: ApiPathString::new(codex_home.path().to_string_lossy()),
}]),
..Default::default()
})
@@ -2744,7 +2745,7 @@ fn environment_params(
.map(|id| {
Ok(TurnEnvironmentParams {
environment_id: (*id).to_string(),
cwd: cwd.to_path_buf().try_into()?,
cwd: ApiPathString::new(cwd.to_string_lossy()),
})
})
.collect()
+47
View File
@@ -66,6 +66,7 @@ use codex_thread_store::ThreadStore;
use codex_thread_store::ThreadStoreError;
use codex_thread_store::UpdateThreadMetadataParams;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::ApiPathString;
use futures::StreamExt;
use futures::stream::FuturesUnordered;
use std::collections::HashMap;
@@ -472,6 +473,52 @@ impl ThreadManager {
Ok(())
}
/// Resolves environment-native cwd strings using metadata from each selected environment.
pub async fn resolve_native_environment_selections(
&self,
environments: impl IntoIterator<Item = (String, ApiPathString)>,
) -> CodexResult<Vec<TurnEnvironmentSelection>> {
let environments = environments.into_iter();
let mut environment_ids = HashSet::new();
let mut selections = Vec::with_capacity(environments.size_hint().0);
for (environment_id, cwd) in environments {
if !environment_ids.insert(environment_id.clone()) {
return Err(CodexErr::InvalidRequest(format!(
"duplicate turn environment id `{environment_id}`"
)));
}
let environment = self
.state
.environment_manager
.get_environment(&environment_id)
.ok_or_else(|| {
CodexErr::InvalidRequest(format!(
"unknown turn environment id `{environment_id}`"
))
})?;
let info = environment.info().await.map_err(|err| {
CodexErr::InvalidRequest(format!(
"failed to get info for environment `{environment_id}`: {err}"
))
})?;
crate::shell::Shell::from_environment_shell_info(info.shell).map_err(|err| {
CodexErr::InvalidRequest(format!(
"failed to resolve shell for environment `{environment_id}`: {err}"
))
})?;
let cwd = cwd.to_path_uri(info.path_convention).map_err(|err| {
CodexErr::InvalidRequest(format!(
"invalid cwd for environment `{environment_id}`: {err}"
))
})?;
selections.push(TurnEnvironmentSelection {
environment_id,
cwd,
});
}
Ok(selections)
}
pub fn get_models_manager(&self) -> SharedModelsManager {
self.state.models_manager.clone()
}
+127
View File
@@ -23,6 +23,8 @@ use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TurnStartedEvent;
use codex_protocol::protocol::UserMessageEvent;
use codex_utils_path_uri::ApiPathString;
use codex_utils_path_uri::PathConvention;
use codex_utils_path_uri::PathUri;
use core_test_support::PathBufExt;
use core_test_support::PathExt;
@@ -298,6 +300,131 @@ async fn shutdown_all_threads_bounded_submits_shutdown_to_every_thread() {
assert!(manager.list_thread_ids().await.is_empty());
}
#[tokio::test]
async fn start_thread_rejects_explicit_local_environment_when_default_provider_is_disabled() {
let temp_dir = tempdir().expect("tempdir");
let mut config = test_config().await;
config.codex_home = temp_dir.path().join("codex-home").abs();
config.cwd = config.codex_home.abs();
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
let runtime_paths = codex_exec_server::ExecServerRuntimePaths::new(
std::env::current_exe().expect("current exe path"),
/*codex_linux_sandbox_exe*/ None,
)
.expect("runtime paths");
let environment_manager = Arc::new(
codex_exec_server::EnvironmentManager::create_for_tests(
Some("none".to_string()),
Some(runtime_paths),
)
.await,
);
let manager = ThreadManager::with_models_provider_and_home_for_tests(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
config.codex_home.to_path_buf(),
environment_manager,
);
let result = manager
.start_thread_with_options(StartThreadOptions {
config: config.clone(),
initial_history: InitialHistory::New,
session_source: None,
thread_source: None,
dynamic_tools: Vec::new(),
metrics_service_name: None,
parent_trace: None,
environments: vec![TurnEnvironmentSelection {
environment_id: "local".to_string(),
cwd: PathUri::from_abs_path(&config.cwd),
}],
thread_extension_init: Default::default(),
})
.await;
let err = match result {
Ok(_) => panic!("explicit local environment should not resolve when provider is disabled"),
Err(err) => err,
};
assert_eq!(err.to_string(), "unknown turn environment id `local`");
assert!(manager.list_thread_ids().await.is_empty());
}
#[tokio::test]
async fn native_environment_selections_use_selected_environment_metadata() {
let temp_dir = tempdir().expect("tempdir");
let mut config = test_config().await;
config.codex_home = temp_dir.path().join("codex-home").abs();
config.cwd = temp_dir.path().join("workspace").abs();
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
std::fs::create_dir_all(&config.cwd).expect("create workspace");
let manager = ThreadManager::with_models_provider_and_home_for_tests(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
config.codex_home.to_path_buf(),
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
);
let selections = manager
.resolve_native_environment_selections([(
"local".to_string(),
ApiPathString::new(config.cwd.display().to_string()),
)])
.await
.expect("resolve local selection");
assert_eq!(
selections,
vec![TurnEnvironmentSelection {
environment_id: "local".to_string(),
cwd: PathUri::from_abs_path(&config.cwd),
}]
);
}
#[tokio::test]
async fn native_environment_selections_reject_duplicate_ids() {
let temp_dir = tempdir().expect("tempdir");
let mut config = test_config().await;
config.codex_home = temp_dir.path().join("codex-home").abs();
config.cwd = temp_dir.path().join("workspace").abs();
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
let manager = ThreadManager::with_models_provider_and_home_for_tests(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
config.codex_home.to_path_buf(),
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
);
let native_cwd = ApiPathString::new(config.cwd.display().to_string());
let error = manager
.resolve_native_environment_selections([
("local".to_string(), native_cwd.clone()),
("local".to_string(), native_cwd),
])
.await
.expect_err("duplicate ids must fail");
assert_eq!(error.to_string(), "duplicate turn environment id `local`");
let error = manager
.resolve_native_environment_selections([(
"local".to_string(),
ApiPathString::new("relative"),
)])
.await
.expect_err("relative cwd must fail at the environment boundary");
assert_eq!(
error.to_string(),
format!(
"invalid cwd for environment `local`: path `relative` is not absolute using {} path syntax",
PathConvention::native()
)
);
}
#[tokio::test]
async fn start_thread_keeps_internal_threads_hidden_from_normal_lookups() {
let temp_dir = tempdir().expect("tempdir");