Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
696c15e
feat(exec-server): add ManifestFile/ManifestEntry types + CODEX_EXEC_…
May 5, 2026
b125c93
feat(exec-server): plumb optional bearer auth token through LazyRemot…
May 5, 2026
2421d5a
feat(exec-server): add Environment::remote_with_auth constructor
May 5, 2026
a38be4a
feat(exec-server): ManifestEnvironmentProvider with validation + per-…
May 5, 2026
db0151a
feat(exec-server): EnvironmentManager prefers manifest with warning; …
May 5, 2026
c14d821
test(exec-server): integration tests for manifest end-to-end + legacy…
May 5, 2026
5438610
feat(core): TurnContext::select_environment helper for id-based env s…
May 5, 2026
5c2e70f
feat(core): add environment_id to UnifiedExecRequest; route via selec…
May 5, 2026
1b2a754
feat(core): add environment_id to ApplyPatchRequest; route via select…
May 5, 2026
865670c
test(core): multi-env routing verified via select_environment
May 5, 2026
03f0098
feat(protocol): add optional environment_id to ShellToolCallParams
May 5, 2026
7d1b2f2
feat(tools): shell tool schema exposes optional environment_id property
May 5, 2026
27b8f73
feat(tools): apply_patch JSON tool schema exposes optional environmen…
May 5, 2026
79f4e6d
feat(core): shell handler propagates environment_id into UnifiedExecR…
May 5, 2026
80a3ae4
feat(core): apply_patch handler propagates environment_id into ApplyP…
May 5, 2026
adc4283
feat(core): ShellRuntime selects environment by id; shell handler pro…
May 5, 2026
7402093
fix(core): shell schema honesty + intercept_apply_patch propagates en…
May 5, 2026
aefa1f7
Revert "feat(core): shell handler propagates environment_id into Unif…
May 6, 2026
ddeb163
Revert "feat(tools): apply_patch JSON tool schema exposes optional en…
May 6, 2026
bbba7c6
Revert "feat(tools): shell tool schema exposes optional environment_i…
May 6, 2026
0d820d5
Revert "feat(protocol): add optional environment_id to ShellToolCallP…
May 6, 2026
5aaae2c
feat(tools): add exec_command_in_environment tool — env-aware mirror …
May 6, 2026
b7a62f0
feat(tools): add apply_patch_in_environment tool — env-aware mirror o…
May 6, 2026
109b75e
feat(tools): add list_environments tool — read-only env catalog
May 6, 2026
02b1e8f
feat(exec-server): Environment.description; ManifestEnvironmentProvid…
May 6, 2026
5b1ab05
feat(tools): add list_dir_in_environment tool — env-aware directory l…
May 6, 2026
e2cd2f6
feat(tools): add view_image_in_environment tool — env-aware image vie…
May 6, 2026
2d2f456
feat(tools): add read_file_in_environment + write_file_in_environment…
May 6, 2026
9165e9a
feat(tools): wire env-aware tool family; gated on env_count >= 2
May 6, 2026
b28de7b
test(core): multi-env tool family end-to-end integration
May 6, 2026
0b27a33
feat(protocol): add ENVIRONMENTS_OPEN_TAG/CLOSE_TAG constants for sys…
May 6, 2026
b34606f
feat(core): AvailableEnvironmentsInstructions fragment for <environme…
May 6, 2026
75e8450
feat(core): inject <environments> block into developer sections for m…
May 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 234 additions & 0 deletions codex-rs/core/src/context/available_environments_instructions.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
//! Renders the `<environments>` developer-section block listing each
//! execution environment available for this turn. Modeled after
//! `available_skills_instructions.rs`.
//!
//! Spec reference: `2026-05-05-codex-app-gateway-and-exec-gateway-design.md`
//! § Subsystem 1, P4. Body advertises the env-aware tool family added in
//! Pa.1–Pa.6 (per the P4 update following Pa.7's `env_count >= 2` gate).

use codex_protocol::protocol::ENVIRONMENTS_CLOSE_TAG;
use codex_protocol::protocol::ENVIRONMENTS_OPEN_TAG;

use super::ContextualUserFragment;

/// One row in the `<environments>` table.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct EnvironmentRow {
pub(crate) environment_id: String,
pub(crate) description: String,
pub(crate) is_default: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AvailableEnvironmentsInstructions {
rows: Vec<EnvironmentRow>,
}

impl AvailableEnvironmentsInstructions {
/// Builds the fragment from the turn's environments.
///
/// Returns `None` when fewer than 2 environments are present — the block
/// has no value when there is nothing for the LLM to choose between (per
/// spec § P4 "absent / single-env turns omit the block"). The gate
/// matches Pa.7's env-aware tool registration.
pub(crate) fn from_turn_environments(
environments: &[crate::session::turn_context::TurnEnvironment],
descriptions: &std::collections::HashMap<String, Option<String>>,
default_environment_id: Option<&str>,
) -> Option<Self> {
if environments.len() < 2 {
return None;
}
let rows = environments
.iter()
.map(|env| EnvironmentRow {
environment_id: env.environment_id.clone(),
description: descriptions
.get(&env.environment_id)
.and_then(|d| d.as_deref())
.unwrap_or("(no description)")
.to_string(),
is_default: default_environment_id == Some(env.environment_id.as_str()),
})
.collect();
Some(Self { rows })
}
}

impl ContextualUserFragment for AvailableEnvironmentsInstructions {
const ROLE: &'static str = "developer";
const START_MARKER: &'static str = ENVIRONMENTS_OPEN_TAG;
const END_MARKER: &'static str = ENVIRONMENTS_CLOSE_TAG;

fn body(&self) -> String {
let mut out = String::new();
out.push_str(
"\nYou have access to the following execution environments. Operations on the\n\
default environment use the standard tools (`shell`, `apply_patch`,\n\
`exec_command`, etc.).\n\n\
For operations on **non-default environments**, use the env-aware tool family\n\
and pass `environment_id` explicitly. Available env-aware tools:\n\n\
- `exec_command_in_environment(environment_id, cmd, ...)` \
— run a command on the named environment\n\
- `apply_patch_in_environment(environment_id, input)` \
— apply a patch on the named environment's filesystem\n\
- `list_dir_in_environment(environment_id, path)` \
— list a directory on the named environment\n\
- `read_file_in_environment(environment_id, path)` \
— read a file on the named environment\n\
- `write_file_in_environment(environment_id, path, content)` \
— write a file on the named environment\n\
- `view_image_in_environment(environment_id, path)` \
— view an image file on the named environment\n\
- `list_environments()` — refresh the catalog below\n\n\
Available environments:\n\n",
);
out.push_str("| id | description | default |\n");
out.push_str("| --- | --- | --- |\n");
for row in &self.rows {
out.push_str(&format!(
"| {} | {} | {} |\n",
escape_table_cell(&row.environment_id),
escape_table_cell(&row.description),
if row.is_default { "yes" } else { "no" },
));
}
out
}
}

/// Escapes pipe and newline characters so a malicious / quirky description
/// cannot break the markdown table rendering. (Per spec § P4 tests.)
fn escape_table_cell(text: &str) -> String {
text.replace('\\', "\\\\")
.replace('|', "\\|")
.replace('\n', " ")
.replace('\r', " ")
}

#[cfg(test)]
mod tests {
use super::*;

fn rows_for(ids_and_defaults: &[(&str, bool)]) -> Vec<EnvironmentRow> {
ids_and_defaults
.iter()
.map(|(id, def)| EnvironmentRow {
environment_id: (*id).to_string(),
description: format!("desc for {id}"),
is_default: *def,
})
.collect()
}

#[test]
fn renders_table_for_multiple_environments() {
let frag = AvailableEnvironmentsInstructions {
rows: rows_for(&[("exe_a", true), ("exe_b", false), ("exe_c", false)]),
};
let body = frag.body();
assert!(body.contains("| exe_a | desc for exe_a | yes |"));
assert!(body.contains("| exe_b | desc for exe_b | no |"));
assert!(body.contains("| exe_c | desc for exe_c | no |"));
}

#[tokio::test]
async fn from_turn_environments_returns_none_for_single_env() {
let cwd = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(
std::env::current_dir().expect("cwd").as_path(),
)
.expect("abs");
let env = std::sync::Arc::new(codex_exec_server::Environment::default_for_tests());
let environments = vec![crate::session::turn_context::TurnEnvironment {
environment_id: "only".into(),
environment: env,
cwd,
shell: "/bin/sh".into(),
}];
let descriptions = std::collections::HashMap::new();
assert!(
AvailableEnvironmentsInstructions::from_turn_environments(
&environments,
&descriptions,
Some("only"),
)
.is_none()
);
}

#[test]
fn escapes_pipe_and_newline_in_descriptions() {
let frag = AvailableEnvironmentsInstructions {
rows: vec![
EnvironmentRow {
environment_id: "x".into(),
description: "evil | desc\nwith newline".into(),
is_default: true,
},
EnvironmentRow {
environment_id: "y".into(),
description: "ok".into(),
is_default: false,
},
],
};
let body = frag.body();
assert!(body.contains("| evil \\| desc with newline |"));
assert!(!body.contains("evil | desc"));
}

#[tokio::test]
async fn default_flag_matches_default_environment_id() {
let cwd = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(
std::env::current_dir().expect("cwd").as_path(),
)
.expect("abs");
let env = std::sync::Arc::new(codex_exec_server::Environment::default_for_tests());
let environments = vec![
crate::session::turn_context::TurnEnvironment {
environment_id: "a".into(),
environment: env.clone(),
cwd: cwd.clone(),
shell: "/bin/sh".into(),
},
crate::session::turn_context::TurnEnvironment {
environment_id: "b".into(),
environment: env,
cwd,
shell: "/bin/sh".into(),
},
];
let mut descriptions = std::collections::HashMap::new();
descriptions.insert("a".to_string(), Some("Alpha".to_string()));
descriptions.insert("b".to_string(), Some("Beta".to_string()));
let frag = AvailableEnvironmentsInstructions::from_turn_environments(
&environments,
&descriptions,
Some("b"),
)
.expect("two envs");
let body = frag.body();
assert!(body.contains("| a | Alpha | no |"));
assert!(body.contains("| b | Beta | yes |"));
}

#[test]
fn body_mentions_env_aware_tool_family() {
let frag = AvailableEnvironmentsInstructions {
rows: rows_for(&[("a", true), ("b", false)]),
};
let body = frag.body();
assert!(
body.contains("exec_command_in_environment"),
"body should mention exec_command_in_environment, got: {body}"
);
assert!(
body.contains("apply_patch_in_environment"),
"body should mention apply_patch_in_environment, got: {body}"
);
assert!(
body.contains("list_environments"),
"body should mention list_environments, got: {body}"
);
}
}
2 changes: 2 additions & 0 deletions codex-rs/core/src/context/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

mod approved_command_prefix_saved;
mod apps_instructions;
mod available_environments_instructions;
mod available_plugins_instructions;
mod available_skills_instructions;
mod collaboration_mode_instructions;
Expand All @@ -27,6 +28,7 @@ mod user_shell_command;

pub(crate) use approved_command_prefix_saved::ApprovedCommandPrefixSaved;
pub(crate) use apps_instructions::AppsInstructions;
pub(crate) use available_environments_instructions::AvailableEnvironmentsInstructions;
pub(crate) use available_plugins_instructions::AvailablePluginsInstructions;
pub(crate) use available_skills_instructions::AvailableSkillsInstructions;
pub(crate) use collaboration_mode_instructions::CollaborationModeInstructions;
Expand Down
25 changes: 25 additions & 0 deletions codex-rs/core/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ use crate::connectors;
use crate::context::ApprovedCommandPrefixSaved;
use crate::context::AppsInstructions;
use crate::context::AvailablePluginsInstructions;
use crate::context::AvailableEnvironmentsInstructions;
use crate::context::AvailableSkillsInstructions;
use crate::context::CollaborationModeInstructions;
use crate::context::ContextualUserFragment;
Expand Down Expand Up @@ -2662,6 +2663,30 @@ impl Session {
developer_sections.push(skills_instructions.render());
}
}
// <environments> block — only rendered when the turn has 2+ environments.
let env_descriptions: std::collections::HashMap<String, Option<String>> = turn_context
.environments
.iter()
.map(|env| {
(
env.environment_id.clone(),
env.environment.description().map(str::to_owned),
)
})
.collect();
let default_env_id = turn_context
.environments
.first()
.map(|env| env.environment_id.as_str());
if let Some(env_instructions) =
AvailableEnvironmentsInstructions::from_turn_environments(
&turn_context.environments,
&env_descriptions,
default_env_id,
)
{
developer_sections.push(env_instructions.render());
}
let loaded_plugins = self
.services
.plugins_manager
Expand Down
3 changes: 2 additions & 1 deletion codex-rs/core/src/session/review.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ pub(super) async fn spawn_review_thread(
)
.with_agent_type_description(crate::agent::role::spawn_tool_spec::build(
&config.agent_roles,
));
))
.with_multi_environment_count(parent_turn_context.environments.len());

let review_prompt = resolved.prompt.clone();
let provider = parent_turn_context.provider.clone();
Expand Down
Loading
Loading