Compare commits

...

8 Commits

Author SHA1 Message Date
Dylan
119b7780bc it sorta works now 2025-11-13 00:26:07 -08:00
Dylan
0b09838b32 wip 1 2025-11-12 15:10:59 -08:00
Eric Traut
ad09c138b9 Fixed status output to use auth information from AuthManager (#6529)
This PR addresses https://github.com/openai/codex/issues/6360. The root
problem is that the TUI was directly loading the `auth.json` file to
access the auth information. It should instead be using the AuthManager,
which records the current auth information. The `auth.json` file can be
overwritten at any time by other instances of the CLI or extension, so
its information can be out of sync with the current instance. The
`/status` command should always report the auth information associated
with the current instance.

An alternative fix for this bug was submitted by @chojs23 in [this
PR](https://github.com/openai/codex/pull/6495). That approach was only a
partial fix.
2025-11-12 10:26:50 -08:00
jif-oai
e00eb50db3 feat: only wait for mutating tools for ghost commit (#6534) 2025-11-12 18:16:32 +00:00
pakrym-oai
7d9ad3effd Fix otel tests (#6541)
Mount responses only once, remove unneeded retries and add a final
assistant messages to complete the turn.
2025-11-12 16:35:34 +00:00
Michael Bolin
c3a710ee14 chore: verify boolean values can be parsed as config overrides (#6516)
This is important to ensure that this:

```
codex --enable unified_exec
```

and this:

```
codex --config features.unified_exec=true
```

are equivalent. Also that when it is passed programmatically:


807e2c27f0/codex-rs/app-server-protocol/src/protocol/v1.rs (L55)

then this should work for `config`:

```json
{"features": {"shell_command_tool": true}}
```

though I believe also this:

```json
{"features.shell_command_tool": true}
```
2025-11-12 08:19:16 -08:00
Michael Bolin
29364f3a9b feat: shell_command tool (#6510)
This adds support for a new variant of the shell tool behind a flag. To
test, run `codex` with `--enable shell_command_tool`, which will
register the tool with Codex under the name `shell_command` that accepts
the following shape:

```python
{
  command: str
  workdir: str | None,
  timeout_ms: int | None,
  with_escalated_permissions: bool | None,
  justification: str | None,
}
```

This is comparable to the existing tool registered under
`shell`/`container.exec`. The primary difference is that it accepts
`command` as a `str` instead of a `str[]`. The `shell_command` tool
executes by running `execvp(["bash", "-lc", command])`, though the exact
arguments to `execvp(3)` depend on the user's default shell.

The hypothesis is that this will simplify things for the model. For
example, on Windows, instead of generating:

```json
{"command": ["pwsh.exe", "-NoLogo", "-Command", "ls -Name"]}
```

The model could simply generate:

```json
{"command": "ls -Name"}
```

As part of this change, I extracted some logic out of `user_shell.rs` as
`Shell::derive_exec_args()` so that it can be reused in
`codex-rs/core/src/tools/handlers/shell.rs`. Note the original code
generated exec arg lists like:

```javascript
["bash", "-lc", command]
["zsh", "-lc", command]
["pwsh.exe", "-NoProfile", "-Command", command]
```

Using `-l` for Bash and Zsh, but then specifying `-NoProfile` for
PowerShell seemed inconsistent to me, so I changed this in the new
implementation while also adding a `use_login_shell: bool` option to
make this explicit. If we decide to add a `login: bool` to
`ShellCommandToolCallParams` like we have for unified exec:


807e2c27f0/codex-rs/core/src/tools/handlers/unified_exec.rs (L33-L34)

Then this should make it straightforward to support.
2025-11-12 08:18:57 -08:00
jif-oai
530db0ad73 feat: warning switch model on resume (#6507)
<img width="1259" height="40" alt="Screenshot 2025-11-11 at 14 01 41"
src="https://github.com/user-attachments/assets/48ead3d2-d89c-4d8a-a578-82d9663dbd88"
/>
2025-11-12 11:13:37 +00:00
30 changed files with 1021 additions and 213 deletions

View File

@@ -151,6 +151,15 @@ mod tests {
assert_eq!(v.as_integer(), Some(42));
}
#[test]
fn parses_bool() {
let true_literal = parse_toml_value("true").expect("parse");
assert_eq!(true_literal.as_bool(), Some(true));
let false_literal = parse_toml_value("false").expect("parse");
assert_eq!(false_literal.as_bool(), Some(false));
}
#[test]
fn fails_on_unquoted_string() {
assert!(parse_toml_value("hello").is_err());

View File

@@ -227,6 +227,14 @@ impl CodexAuth {
})
}
/// Raw plan string from the ID token (including unknown/new plan types).
pub fn raw_plan_type(&self) -> Option<String> {
self.get_plan_type().map(|plan| match plan {
InternalPlanType::Known(k) => format!("{k:?}"),
InternalPlanType::Unknown(raw) => raw,
})
}
/// Raw internal plan value from the ID token.
/// Exposes the underlying `token_data::PlanType` without mapping it to the
/// public `AccountPlanType`. Use this when downstream code needs to inspect
@@ -335,7 +343,10 @@ pub fn save_auth(
}
/// Load CLI auth data using the configured credential store backend.
/// Returns `None` when no credentials are stored.
/// Returns `None` when no credentials are stored. This function is
/// provided only for tests. Production code should not directly load
/// from the auth.json storage. It should use the AuthManager abstraction
/// instead.
pub fn load_auth_dot_json(
codex_home: &Path,
auth_credentials_store_mode: AuthCredentialsStoreMode,

View File

@@ -104,7 +104,7 @@ pub fn extract_bash_command(command: &[String]) -> Option<(&str, &str)> {
let [shell, flag, script] = command else {
return None;
};
if flag != "-lc" || !is_well_known_sh_shell(shell) {
if !matches!(flag.as_str(), "-lc" | "-c") || !is_well_known_sh_shell(shell) {
return None;
}
Some((shell, script))

View File

@@ -97,6 +97,7 @@ use crate::protocol::Submission;
use crate::protocol::TokenCountEvent;
use crate::protocol::TokenUsage;
use crate::protocol::TurnDiffEvent;
use crate::protocol::WarningEvent;
use crate::rollout::RolloutRecorder;
use crate::rollout::RolloutRecorderParams;
use crate::shell;
@@ -674,6 +675,34 @@ impl Session {
let rollout_items = conversation_history.get_rollout_items();
let persist = matches!(conversation_history, InitialHistory::Forked(_));
// If resuming, warn when the last recorded model differs from the current one.
if let InitialHistory::Resumed(_) = conversation_history
&& let Some(prev) = rollout_items.iter().rev().find_map(|it| {
if let RolloutItem::TurnContext(ctx) = it {
Some(ctx.model.as_str())
} else {
None
}
})
{
let curr = turn_context.client.get_model();
if prev != curr {
warn!(
"resuming session with different model: previous={prev}, current={curr}"
);
self.send_event(
&turn_context,
EventMsg::Warning(WarningEvent {
message: format!(
"This session was recorded with model `{prev}` but is resuming with `{curr}`. \
Consider switching back to `{prev}` as it may affect Codex performance."
),
}),
)
.await;
}
}
// Always add response items to conversation history
let reconstructed_history =
self.reconstruct_history_from_rollout(&turn_context, &rollout_items);

View File

@@ -30,6 +30,7 @@ use crate::spawn::StdioPolicy;
use crate::spawn::spawn_child_async;
const DEFAULT_TIMEOUT_MS: u64 = 10_000;
const MAX_EXEC_TIMEOUT_MS: u64 = 120_000;
// Hardcode these since it does not seem worth including the libc crate just
// for these.
@@ -59,7 +60,9 @@ pub struct ExecParams {
impl ExecParams {
pub fn timeout_duration(&self) -> Duration {
Duration::from_millis(self.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS))
let raw_timeout_ms = self.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS);
let clamped_timeout_ms = raw_timeout_ms.min(MAX_EXEC_TIMEOUT_MS);
Duration::from_millis(clamped_timeout_ms)
}
}
@@ -182,6 +185,8 @@ async fn exec_windows_sandbox(
..
} = params;
let timeout_ms = timeout_ms.map(|value| value.min(MAX_EXEC_TIMEOUT_MS));
let policy_str = match sandbox_policy {
SandboxPolicy::DangerFullAccess => "workspace-write",
SandboxPolicy::ReadOnly => "read-only",

View File

@@ -25,6 +25,23 @@ where
ShellEnvironmentPolicyInherit::All => vars.into_iter().collect(),
ShellEnvironmentPolicyInherit::None => HashMap::new(),
ShellEnvironmentPolicyInherit::Core => {
#[cfg(target_os = "windows")]
const CORE_VARS: &[&str] = &[
"COMSPEC",
"HOME",
"LOGNAME",
"PATH",
"PATHEXT",
"SYSTEMROOT",
"TEMP",
"TMP",
"TMPDIR",
"USER",
"USERNAME",
"USERPROFILE",
"WINDIR",
];
#[cfg(not(target_os = "windows"))]
const CORE_VARS: &[&str] = &[
"HOME", "LOGNAME", "PATH", "SHELL", "USER", "USERNAME", "TMPDIR", "TEMP", "TMP",
];

View File

@@ -29,6 +29,9 @@ pub enum Stage {
pub enum Feature {
/// Use the single unified PTY-backed exec tool.
UnifiedExec,
/// Use the shell command tool that takes `command` as a single string of
/// shell instead of an array of args passed to `execvp(3)`.
ShellCommandTool,
/// Enable experimental RMCP features such as OAuth login.
RmcpClient,
/// Include the freeform apply_patch tool.
@@ -250,6 +253,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::ShellCommandTool,
key: "shell_command_tool",
stage: Stage::Experimental,
default_enabled: false,
},
FeatureSpec {
id: Feature::RmcpClient,
key: "rmcp_client",

View File

@@ -31,16 +31,37 @@ pub enum Shell {
impl Shell {
pub fn name(&self) -> Option<String> {
match self {
Shell::Zsh(zsh) => std::path::Path::new(&zsh.shell_path)
.file_name()
.map(|s| s.to_string_lossy().to_string()),
Shell::Bash(bash) => std::path::Path::new(&bash.shell_path)
.file_name()
.map(|s| s.to_string_lossy().to_string()),
Shell::Zsh(ZshShell { shell_path, .. }) | Shell::Bash(BashShell { shell_path, .. }) => {
std::path::Path::new(shell_path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
}
Shell::PowerShell(ps) => Some(ps.exe.clone()),
Shell::Unknown => None,
}
}
/// Takes a string of shell and returns the full list of command args to
/// use with `exec()` to run the shell command.
pub fn derive_exec_args(&self, command: &str, use_login_shell: bool) -> Vec<String> {
match self {
Shell::Zsh(ZshShell { shell_path, .. }) | Shell::Bash(BashShell { shell_path, .. }) => {
let arg = if use_login_shell { "-lc" } else { "-c" };
vec![shell_path.clone(), arg.to_string(), command.to_string()]
}
Shell::PowerShell(ps) => {
let mut args = vec![ps.exe.clone(), "-NoLogo".to_string()];
if !use_login_shell {
args.push("-NoProfile".to_string());
}
args.push("-Command".to_string());
args.push(command.to_string());
args
}
Shell::Unknown => shlex::split(command).unwrap_or_else(|| vec![command.to_string()]),
}
}
}
#[cfg(unix)]

View File

@@ -63,27 +63,10 @@ impl SessionTask for UserShellCommandTask {
// Execute the user's script under their default shell when known; this
// allows commands that use shell features (pipes, &&, redirects, etc.).
// We do not source rc files or otherwise reformat the script.
let shell_invocation = match session.user_shell() {
crate::shell::Shell::Zsh(zsh) => vec![
zsh.shell_path.clone(),
"-lc".to_string(),
self.command.clone(),
],
crate::shell::Shell::Bash(bash) => vec![
bash.shell_path.clone(),
"-lc".to_string(),
self.command.clone(),
],
crate::shell::Shell::PowerShell(ps) => vec![
ps.exe.clone(),
"-NoProfile".to_string(),
"-Command".to_string(),
self.command.clone(),
],
crate::shell::Shell::Unknown => {
shlex::split(&self.command).unwrap_or_else(|| vec![self.command.clone()])
}
};
let use_login_shell = true;
let shell_invocation = session
.user_shell()
.derive_exec_args(&self.command, use_login_shell);
let call_id = Uuid::new_v4().to_string();
let raw_command = self.command.clone();

View File

@@ -42,6 +42,10 @@ impl ToolHandler for ApplyPatchHandler {
)
}
fn is_mutating(&self, _invocation: &ToolInvocation) -> bool {
true
}
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
let ToolInvocation {
session,

View File

@@ -19,6 +19,7 @@ pub use mcp::McpHandler;
pub use mcp_resource::McpResourceHandler;
pub use plan::PlanHandler;
pub use read_file::ReadFileHandler;
pub use shell::ShellCommandHandler;
pub use shell::ShellHandler;
pub use test_sync::TestSyncHandler;
pub use unified_exec::UnifiedExecHandler;

View File

@@ -1,4 +1,5 @@
use async_trait::async_trait;
use codex_protocol::models::ShellCommandToolCallParams;
use codex_protocol::models::ShellToolCallParams;
use std::sync::Arc;
@@ -9,6 +10,7 @@ use crate::codex::TurnContext;
use crate::exec::ExecParams;
use crate::exec_env::create_env;
use crate::function_tool::FunctionCallError;
use crate::is_safe_command::is_known_safe_command;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
@@ -25,6 +27,8 @@ use crate::tools::sandboxing::ToolCtx;
pub struct ShellHandler;
pub struct ShellCommandHandler;
impl ShellHandler {
fn to_exec_params(params: ShellToolCallParams, turn_context: &TurnContext) -> ExecParams {
ExecParams {
@@ -39,6 +43,28 @@ impl ShellHandler {
}
}
impl ShellCommandHandler {
fn to_exec_params(
params: ShellCommandToolCallParams,
session: &crate::codex::Session,
turn_context: &TurnContext,
) -> ExecParams {
let shell = session.user_shell();
let use_login_shell = true;
let command = shell.derive_exec_args(&params.command, use_login_shell);
ExecParams {
command,
cwd: turn_context.resolve_path(params.workdir.clone()),
timeout_ms: params.timeout_ms,
env: create_env(&turn_context.shell_environment_policy),
with_escalated_permissions: params.with_escalated_permissions,
justification: params.justification,
arg0: None,
}
}
}
#[async_trait]
impl ToolHandler for ShellHandler {
fn kind(&self) -> ToolKind {
@@ -52,6 +78,18 @@ impl ToolHandler for ShellHandler {
)
}
fn is_mutating(&self, invocation: &ToolInvocation) -> bool {
match &invocation.payload {
ToolPayload::Function { arguments } => {
serde_json::from_str::<ShellToolCallParams>(arguments)
.map(|params| !is_known_safe_command(&params.command))
.unwrap_or(true)
}
ToolPayload::LocalShell { params } => !is_known_safe_command(&params.command),
_ => true, // unknown payloads => assume mutating
}
}
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
let ToolInvocation {
session,
@@ -102,6 +140,49 @@ impl ToolHandler for ShellHandler {
}
}
#[async_trait]
impl ToolHandler for ShellCommandHandler {
fn kind(&self) -> ToolKind {
ToolKind::Function
}
fn matches_kind(&self, payload: &ToolPayload) -> bool {
matches!(payload, ToolPayload::Function { .. })
}
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
let ToolInvocation {
session,
turn,
tracker,
call_id,
tool_name,
payload,
} = invocation;
let ToolPayload::Function { arguments } = payload else {
return Err(FunctionCallError::RespondToModel(format!(
"unsupported payload for shell_command handler: {tool_name}"
)));
};
let params: ShellCommandToolCallParams = serde_json::from_str(&arguments).map_err(|e| {
FunctionCallError::RespondToModel(format!("failed to parse function arguments: {e:?}"))
})?;
let exec_params = Self::to_exec_params(params, session.as_ref(), turn.as_ref());
ShellHandler::run_exec_like(
tool_name.as_str(),
exec_params,
session,
turn,
tracker,
call_id,
false,
)
.await
}
}
impl ShellHandler {
async fn run_exec_like(
tool_name: &str,
@@ -240,3 +321,49 @@ impl ShellHandler {
})
}
}
#[cfg(test)]
mod tests {
use crate::is_safe_command::is_known_safe_command;
use crate::shell::BashShell;
use crate::shell::Shell;
use crate::shell::ZshShell;
/// The logic for is_known_safe_command() has heuristics for known shells,
/// so we must ensure the commands generated by [ShellCommandHandler] can be
/// recognized as safe if the `command` is safe.
#[test]
fn commands_generated_by_shell_command_handler_can_be_matched_by_is_known_safe_command() {
let bash_shell = Shell::Bash(BashShell {
shell_path: "/bin/bash".to_string(),
bashrc_path: "/home/user/.bashrc".to_string(),
});
assert_safe(&bash_shell, "ls -la");
let zsh_shell = Shell::Zsh(ZshShell {
shell_path: "/bin/zsh".to_string(),
zshrc_path: "/home/user/.zshrc".to_string(),
});
assert_safe(&zsh_shell, "ls -la");
#[cfg(target_os = "windows")]
{
use crate::shell::PowerShellConfig;
let powershell = Shell::PowerShell(PowerShellConfig {
exe: "pwsh.exe".to_string(),
bash_exe_fallback: None,
});
assert_safe(&powershell, "ls -Name");
}
}
fn assert_safe(shell: &Shell, command: &str) {
assert!(is_known_safe_command(
&shell.derive_exec_args(command, /* use_login_shell */ true)
));
assert!(is_known_safe_command(
&shell.derive_exec_args(command, /* use_login_shell */ false)
));
}
}

View File

@@ -1,9 +1,7 @@
use std::path::PathBuf;
use async_trait::async_trait;
use serde::Deserialize;
use crate::function_tool::FunctionCallError;
use crate::is_safe_command::is_known_safe_command;
use crate::protocol::EventMsg;
use crate::protocol::ExecCommandOutputDeltaEvent;
use crate::protocol::ExecOutputStream;
@@ -20,6 +18,8 @@ use crate::unified_exec::UnifiedExecContext;
use crate::unified_exec::UnifiedExecResponse;
use crate::unified_exec::UnifiedExecSessionManager;
use crate::unified_exec::WriteStdinRequest;
use async_trait::async_trait;
use serde::Deserialize;
pub struct UnifiedExecHandler;
@@ -54,7 +54,14 @@ struct WriteStdinArgs {
}
fn default_shell() -> String {
"/bin/bash".to_string()
#[cfg(target_os = "windows")]
{
"powershell.exe".to_string()
}
#[cfg(not(target_os = "windows"))]
{
"/bin/bash".to_string()
}
}
fn default_login() -> bool {
@@ -74,6 +81,19 @@ impl ToolHandler for UnifiedExecHandler {
)
}
fn is_mutating(&self, invocation: &ToolInvocation) -> bool {
let (ToolPayload::Function { arguments } | ToolPayload::UnifiedExec { arguments }) =
&invocation.payload
else {
return true;
};
let Ok(params) = serde_json::from_str::<ExecCommandArgs>(arguments) else {
return true;
};
!is_known_safe_command(&["bash".to_string(), "-lc".to_string(), params.cmd])
}
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
let ToolInvocation {
session,

View File

@@ -16,7 +16,6 @@ use crate::tools::router::ToolCall;
use crate::tools::router::ToolRouter;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use codex_utils_readiness::Readiness;
pub(crate) struct ToolCallRuntime {
router: Arc<ToolRouter>,
@@ -55,7 +54,6 @@ impl ToolCallRuntime {
let tracker = Arc::clone(&self.tracker);
let lock = Arc::clone(&self.parallel_execution);
let started = Instant::now();
let readiness = self.turn_context.tool_call_gate.clone();
let handle: AbortOnDropHandle<Result<ResponseInputItem, FunctionCallError>> =
AbortOnDropHandle::new(tokio::spawn(async move {
@@ -65,9 +63,6 @@ impl ToolCallRuntime {
Ok(Self::aborted_response(&call, secs))
},
res = async {
tracing::trace!("waiting for tool gate");
readiness.wait_ready().await;
tracing::trace!("tool gate released");
let _guard = if supports_parallel {
Either::Left(lock.read().await)
} else {

View File

@@ -2,15 +2,15 @@ use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use codex_protocol::models::ResponseInputItem;
use tracing::warn;
use crate::client_common::tools::ToolSpec;
use crate::function_tool::FunctionCallError;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use async_trait::async_trait;
use codex_protocol::models::ResponseInputItem;
use codex_utils_readiness::Readiness;
use tracing::warn;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ToolKind {
@@ -30,6 +30,10 @@ pub trait ToolHandler: Send + Sync {
)
}
fn is_mutating(&self, _invocation: &ToolInvocation) -> bool {
false
}
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError>;
}
@@ -106,6 +110,11 @@ impl ToolRegistry {
let output_cell = &output_cell;
let invocation = invocation;
async move {
if handler.is_mutating(&invocation) {
tracing::trace!("waiting for tool gate");
invocation.turn.tool_call_gate.wait_ready().await;
tracing::trace!("tool gate released");
}
match handler.handle(invocation).await {
Ok(output) => {
let preview = output.log_preview();

View File

@@ -20,6 +20,8 @@ pub enum ConfigShellToolType {
Default,
Local,
UnifiedExec,
/// Takes a command as a single string to be run in the user's default shell.
ShellCommand,
}
#[derive(Debug, Clone)]
@@ -48,6 +50,8 @@ impl ToolsConfig {
let shell_type = if features.enabled(Feature::UnifiedExec) {
ConfigShellToolType::UnifiedExec
} else if features.enabled(Feature::ShellCommandTool) {
ConfigShellToolType::ShellCommand
} else {
model_family.shell_type.clone()
};
@@ -273,7 +277,10 @@ fn create_shell_tool() -> ToolSpec {
properties.insert(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some("The timeout for the command in milliseconds".to_string()),
description: Some(
"The timeout for the command in milliseconds (clamped to a maximum of 120000 ms / 2 minutes)."
.to_string(),
),
},
);
@@ -302,6 +309,56 @@ fn create_shell_tool() -> ToolSpec {
})
}
fn create_shell_command_tool() -> ToolSpec {
let mut properties = BTreeMap::new();
properties.insert(
"command".to_string(),
JsonSchema::String {
description: Some(
"The shell script to execute in the user's default shell".to_string(),
),
},
);
properties.insert(
"workdir".to_string(),
JsonSchema::String {
description: Some("The working directory to execute the command in".to_string()),
},
);
properties.insert(
"timeout_ms".to_string(),
JsonSchema::Number {
description: Some(
"The timeout for the command in milliseconds (clamped to a maximum of 120000 ms / 2 minutes)."
.to_string(),
),
},
);
properties.insert(
"with_escalated_permissions".to_string(),
JsonSchema::Boolean {
description: Some("Whether to request escalated permissions. Set to true if command needs to be run without sandbox restrictions".to_string()),
},
);
properties.insert(
"justification".to_string(),
JsonSchema::String {
description: Some("Only set if with_escalated_permissions is true. 1-sentence explanation of why we want to run this command.".to_string()),
},
);
ToolSpec::Function(ResponsesApiTool {
name: "shell_command".to_string(),
description: "Runs a shell command string and returns its output.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["command".to_string()]),
additional_properties: Some(false.into()),
},
})
}
fn create_view_image_tool() -> ToolSpec {
// Support only local filesystem path.
let mut properties = BTreeMap::new();
@@ -891,6 +948,7 @@ pub(crate) fn build_specs(
use crate::tools::handlers::McpResourceHandler;
use crate::tools::handlers::PlanHandler;
use crate::tools::handlers::ReadFileHandler;
use crate::tools::handlers::ShellCommandHandler;
use crate::tools::handlers::ShellHandler;
use crate::tools::handlers::TestSyncHandler;
use crate::tools::handlers::UnifiedExecHandler;
@@ -906,6 +964,7 @@ pub(crate) fn build_specs(
let view_image_handler = Arc::new(ViewImageHandler);
let mcp_handler = Arc::new(McpHandler);
let mcp_resource_handler = Arc::new(McpResourceHandler);
let shell_command_handler = Arc::new(ShellCommandHandler);
match &config.shell_type {
ConfigShellToolType::Default => {
@@ -920,12 +979,16 @@ pub(crate) fn build_specs(
builder.register_handler("exec_command", unified_exec_handler.clone());
builder.register_handler("write_stdin", unified_exec_handler);
}
ConfigShellToolType::ShellCommand => {
builder.push_spec(create_shell_command_tool());
}
}
// Always register shell aliases so older prompts remain compatible.
builder.register_handler("shell", shell_handler.clone());
builder.register_handler("container.exec", shell_handler.clone());
builder.register_handler("local_shell", shell_handler);
builder.register_handler("shell_command", shell_command_handler);
builder.push_spec_with_parallel_support(create_list_mcp_resources_tool(), true);
builder.push_spec_with_parallel_support(create_list_mcp_resource_templates_tool(), true);
@@ -1061,6 +1124,7 @@ mod tests {
ConfigShellToolType::Default => Some("shell"),
ConfigShellToolType::Local => Some("local_shell"),
ConfigShellToolType::UnifiedExec => None,
ConfigShellToolType::ShellCommand => Some("shell_command"),
}
}
@@ -1293,6 +1357,22 @@ mod tests {
assert_contains_tool_names(&tools, &subset);
}
#[test]
fn test_build_specs_shell_command_present() {
assert_model_tools(
"codex-mini-latest",
Features::with_defaults().enable(Feature::ShellCommandTool),
&[
"shell_command",
"list_mcp_resources",
"list_mcp_resource_templates",
"read_mcp_resource",
"update_plan",
"view_image",
],
);
}
#[test]
#[ignore]
fn test_parallel_support_flags() {
@@ -1748,6 +1828,21 @@ mod tests {
assert_eq!(description, expected);
}
#[test]
fn test_shell_command_tool() {
let tool = super::create_shell_command_tool();
let ToolSpec::Function(ResponsesApiTool {
description, name, ..
}) = &tool
else {
panic!("expected function tool");
};
assert_eq!(name, "shell_command");
let expected = "Runs a shell command string and returns its output.";
assert_eq!(description, expected);
}
#[test]
fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() {
let model_family = find_family_for_model("gpt-5-codex")

View File

@@ -43,7 +43,7 @@ pub(crate) use session::UnifiedExecSession;
pub(crate) const DEFAULT_YIELD_TIME_MS: u64 = 10_000;
pub(crate) const MIN_YIELD_TIME_MS: u64 = 250;
pub(crate) const MAX_YIELD_TIME_MS: u64 = 30_000;
pub(crate) const MAX_YIELD_TIME_MS: u64 = 120_000;
pub(crate) const DEFAULT_MAX_OUTPUT_TOKENS: usize = 10_000;
pub(crate) const UNIFIED_EXEC_OUTPUT_MAX_BYTES: usize = 1024 * 1024; // 1 MiB

View File

@@ -130,51 +130,58 @@ impl UnifiedExecSession {
self.session.exit_code()
}
async fn snapshot_output(&self) -> Vec<Vec<u8>> {
let guard = self.output_buffer.lock().await;
guard.snapshot()
}
fn sandbox_type(&self) -> SandboxType {
self.sandbox_type
}
pub(super) async fn check_for_sandbox_denial(&self) -> Result<(), UnifiedExecError> {
if self.sandbox_type() == SandboxType::None || !self.has_exited() {
return Ok(());
}
pub(super) fn check_for_sandbox_denial(
&self,
) -> impl std::future::Future<Output = Result<(), UnifiedExecError>> + Send {
let sandbox_type = self.sandbox_type();
let has_exited = self.has_exited();
let output_notify = Arc::clone(&self.output_notify);
let output_buffer = Arc::clone(&self.output_buffer);
let exit_code = self.exit_code();
let _ =
tokio::time::timeout(Duration::from_millis(20), self.output_notify.notified()).await;
async move {
if sandbox_type == SandboxType::None || !has_exited {
return Ok(());
}
let collected_chunks = self.snapshot_output().await;
let mut aggregated: Vec<u8> = Vec::new();
for chunk in collected_chunks {
aggregated.extend_from_slice(&chunk);
}
let aggregated_text = String::from_utf8_lossy(&aggregated).to_string();
let exit_code = self.exit_code().unwrap_or(-1);
let _ = tokio::time::timeout(Duration::from_millis(20), output_notify.notified()).await;
let exec_output = ExecToolCallOutput {
exit_code,
stdout: StreamOutput::new(aggregated_text.clone()),
stderr: StreamOutput::new(String::new()),
aggregated_output: StreamOutput::new(aggregated_text.clone()),
duration: Duration::ZERO,
timed_out: false,
};
if is_likely_sandbox_denied(self.sandbox_type(), &exec_output) {
let (snippet, _) = truncate_middle(&aggregated_text, UNIFIED_EXEC_OUTPUT_MAX_BYTES);
let message = if snippet.is_empty() {
format!("exit code {exit_code}")
} else {
snippet
let collected_chunks = {
let guard = output_buffer.lock().await;
guard.snapshot()
};
return Err(UnifiedExecError::sandbox_denied(message, exec_output));
}
let mut aggregated: Vec<u8> = Vec::new();
for chunk in collected_chunks {
aggregated.extend_from_slice(&chunk);
}
let aggregated_text = String::from_utf8_lossy(&aggregated).to_string();
let exit_code = exit_code.unwrap_or(-1);
Ok(())
let exec_output = ExecToolCallOutput {
exit_code,
stdout: StreamOutput::new(aggregated_text.clone()),
stderr: StreamOutput::new(String::new()),
aggregated_output: StreamOutput::new(aggregated_text.clone()),
duration: Duration::ZERO,
timed_out: false,
};
if is_likely_sandbox_denied(sandbox_type, &exec_output) {
let (snippet, _) = truncate_middle(&aggregated_text, UNIFIED_EXEC_OUTPUT_MAX_BYTES);
let message = if snippet.is_empty() {
format!("exit code {exit_code}")
} else {
snippet
};
return Err(UnifiedExecError::sandbox_denied(message, exec_output));
}
Ok(())
}
}
pub(super) async fn from_spawned(

View File

@@ -43,12 +43,7 @@ impl UnifiedExecSessionManager {
.workdir
.clone()
.unwrap_or_else(|| context.turn.cwd.clone());
let shell_flag = if request.login { "-lc" } else { "-c" };
let command = vec![
request.shell.to_string(),
shell_flag.to_string(),
request.command.to_string(),
];
let command = Self::build_shell_command(request.shell, request.login, request.command);
let session = self
.open_session_with_sandbox(
@@ -111,6 +106,30 @@ impl UnifiedExecSessionManager {
Ok(response)
}
fn build_shell_command(shell: &str, login: bool, command: &str) -> Vec<String> {
#[cfg(target_os = "windows")]
{
let _ = login;
let shell_lower = shell.to_ascii_lowercase();
let flag = if shell_lower.contains("cmd") {
"/C"
} else {
"-Command"
};
vec![shell.to_string(), flag.to_string(), command.to_string()]
}
#[cfg(not(target_os = "windows"))]
{
let shell_flag = if login { "-lc" } else { "-c" };
vec![
shell.to_string(),
shell_flag.to_string(),
command.to_string(),
]
}
}
pub(crate) async fn write_stdin(
&self,
request: WriteStdinRequest<'_>,

View File

@@ -446,12 +446,6 @@ pub async fn mount_sse_once(server: &MockServer, body: String) -> ResponseMock {
response_mock
}
pub async fn mount_sse(server: &MockServer, body: String) -> ResponseMock {
let (mock, response_mock) = base_mock();
mock.respond_with(sse_response(body)).mount(server).await;
response_mock
}
pub async fn start_mock_server() -> MockServer {
MockServer::builder()
.body_print_limit(BodyPrintLimit::Limited(80_000))

View File

@@ -9,7 +9,6 @@ use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_custom_tool_call;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::mount_sse;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::sse;
use core_test_support::responses::start_mock_server;
@@ -103,8 +102,6 @@ async fn process_sse_emits_failed_event_on_parse_error() {
let TestCodex { codex, .. } = test_codex()
.with_config(move |config| {
config.features.disable(Feature::GhostCommit);
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -144,8 +141,6 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed()
let TestCodex { codex, .. } = test_codex()
.with_config(move |config| {
config.features.disable(Feature::GhostCommit);
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -193,12 +188,18 @@ async fn process_sse_failed_event_records_response_error_message() {
})]),
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(move |config| {
config.features.disable(Feature::GhostCommit);
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -244,12 +245,18 @@ async fn process_sse_failed_event_logs_parse_error() {
})]),
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(move |config| {
config.features.disable(Feature::GhostCommit);
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -294,8 +301,6 @@ async fn process_sse_failed_event_logs_missing_error() {
let TestCodex { codex, .. } = test_codex()
.with_config(move |config| {
config.features.disable(Feature::GhostCommit);
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -337,11 +342,18 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() {
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(move |config| {
config.features.disable(Feature::GhostCommit);
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -430,7 +442,7 @@ async fn process_sse_emits_completed_telemetry() {
async fn handle_response_item_records_tool_result_for_custom_tool_call() {
let server = start_mock_server().await;
mount_sse(
mount_sse_once(
&server,
sse(vec![
ev_custom_tool_call(
@@ -442,12 +454,18 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() {
]),
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(move |config| {
config.features.disable(Feature::GhostCommit);
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -494,7 +512,7 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() {
async fn handle_response_item_records_tool_result_for_function_call() {
let server = start_mock_server().await;
mount_sse(
mount_sse_once(
&server,
sse(vec![
ev_function_call("function-call", "nonexistent", "{\"value\":1}"),
@@ -503,11 +521,18 @@ async fn handle_response_item_records_tool_result_for_function_call() {
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(move |config| {
config.features.disable(Feature::GhostCommit);
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -554,7 +579,7 @@ async fn handle_response_item_records_tool_result_for_function_call() {
async fn handle_response_item_records_tool_result_for_local_shell_missing_ids() {
let server = start_mock_server().await;
mount_sse(
mount_sse_once(
&server,
sse(vec![
serde_json::json!({
@@ -573,11 +598,18 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids()
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(move |config| {
config.features.disable(Feature::GhostCommit);
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -618,7 +650,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids()
async fn handle_response_item_records_tool_result_for_local_shell_call() {
let server = start_mock_server().await;
mount_sse(
mount_sse_once(
&server,
sse(vec![
ev_local_shell_call("shell-call", "completed", vec!["/bin/echo", "shell"]),
@@ -627,11 +659,18 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() {
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(move |config| {
config.features.disable(Feature::GhostCommit);
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -710,10 +749,23 @@ fn tool_decision_assertion<'a>(
#[traced_test]
async fn handle_container_exec_autoapprove_from_config_records_tool_decision() {
let server = start_mock_server().await;
mount_sse(
mount_sse_once(
&server,
sse(vec![
ev_local_shell_call("auto_config_call", "completed", vec!["/bin/echo", "hello"]),
ev_local_shell_call(
"auto_config_call",
"completed",
vec!["/bin/echo", "local shell"],
),
ev_completed("done"),
]),
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
@@ -723,8 +775,6 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() {
.with_config(|config| {
config.approval_policy = AskForApproval::OnRequest;
config.sandbox_policy = SandboxPolicy::DangerFullAccess;
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -739,7 +789,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() {
.await
.unwrap();
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TokenCount(_))).await;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await;
logs_assert(tool_decision_assertion(
"auto_config_call",
@@ -752,7 +802,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() {
#[traced_test]
async fn handle_container_exec_user_approved_records_tool_decision() {
let server = start_mock_server().await;
mount_sse(
mount_sse_once(
&server,
sse(vec![
ev_local_shell_call("user_approved_call", "completed", vec!["/bin/date"]),
@@ -761,11 +811,18 @@ async fn handle_container_exec_user_approved_records_tool_decision() {
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(|config| {
config.approval_policy = AskForApproval::UnlessTrusted;
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -804,7 +861,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() {
async fn handle_container_exec_user_approved_for_session_records_tool_decision() {
let server = start_mock_server().await;
mount_sse(
mount_sse_once(
&server,
sse(vec![
ev_local_shell_call("user_approved_session_call", "completed", vec!["/bin/date"]),
@@ -812,12 +869,18 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision()
]),
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(|config| {
config.approval_policy = AskForApproval::UnlessTrusted;
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -856,7 +919,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision()
async fn handle_sandbox_error_user_approves_retry_records_tool_decision() {
let server = start_mock_server().await;
mount_sse(
mount_sse_once(
&server,
sse(vec![
ev_local_shell_call("sandbox_retry_call", "completed", vec!["/bin/date"]),
@@ -864,12 +927,18 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() {
]),
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(|config| {
config.approval_policy = AskForApproval::UnlessTrusted;
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -908,7 +977,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() {
async fn handle_container_exec_user_denies_records_tool_decision() {
let server = start_mock_server().await;
mount_sse(
mount_sse_once(
&server,
sse(vec![
ev_local_shell_call("user_denied_call", "completed", vec!["/bin/date"]),
@@ -917,11 +986,17 @@ async fn handle_container_exec_user_denies_records_tool_decision() {
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(|config| {
config.approval_policy = AskForApproval::UnlessTrusted;
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -960,7 +1035,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() {
async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() {
let server = start_mock_server().await;
mount_sse(
mount_sse_once(
&server,
sse(vec![
ev_local_shell_call("sandbox_session_call", "completed", vec!["/bin/date"]),
@@ -968,12 +1043,18 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision()
]),
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(|config| {
config.approval_policy = AskForApproval::UnlessTrusted;
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await
@@ -1012,7 +1093,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision()
async fn handle_sandbox_error_user_denies_records_tool_decision() {
let server = start_mock_server().await;
mount_sse(
mount_sse_once(
&server,
sse(vec![
ev_local_shell_call("sandbox_deny_call", "completed", vec!["/bin/date"]),
@@ -1021,11 +1102,18 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() {
)
.await;
mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "local shell done"),
ev_completed("done"),
]),
)
.await;
let TestCodex { codex, .. } = test_codex()
.with_config(|config| {
config.approval_policy = AskForApproval::UnlessTrusted;
config.model_provider.request_max_retries = Some(0);
config.model_provider.stream_max_retries = Some(0);
})
.build(&server)
.await

View File

@@ -0,0 +1,70 @@
#![allow(clippy::unwrap_used, clippy::expect_used)]
use codex_core::AuthManager;
use codex_core::CodexAuth;
use codex_core::ConversationManager;
use codex_core::NewConversation;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InitialHistory;
use codex_core::protocol::ResumedHistory;
use codex_core::protocol::RolloutItem;
use codex_core::protocol::TurnContextItem;
use codex_core::protocol::WarningEvent;
use codex_protocol::ConversationId;
use core::time::Duration;
use core_test_support::load_default_config_for_test;
use core_test_support::wait_for_event;
use tempfile::TempDir;
fn resume_history(config: &codex_core::config::Config, previous_model: &str, rollout_path: &std::path::Path) -> InitialHistory {
let turn_ctx = TurnContextItem {
cwd: config.cwd.clone(),
approval_policy: config.approval_policy,
sandbox_policy: config.sandbox_policy.clone(),
model: previous_model.to_string(),
effort: config.model_reasoning_effort,
summary: config.model_reasoning_summary,
};
InitialHistory::Resumed(ResumedHistory {
conversation_id: ConversationId::default(),
history: vec![RolloutItem::TurnContext(turn_ctx)],
rollout_path: rollout_path.to_path_buf(),
})
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn emits_warning_when_resumed_model_differs() {
// Arrange a config with a current model and a prior rollout recorded under a different model.
let home = TempDir::new().expect("tempdir");
let mut config = load_default_config_for_test(&home);
config.model = "current-model".to_string();
// Ensure cwd is absolute (the helper sets it to the temp dir already).
assert!(config.cwd.is_absolute());
let rollout_path = home.path().join("rollout.jsonl");
std::fs::write(&rollout_path, "").expect("create rollout placeholder");
let initial_history = resume_history(&config, "previous-model", &rollout_path);
let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("test"));
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
// Act: resume the conversation.
let NewConversation { conversation, .. } = conversation_manager
.resume_conversation_with_history(config, initial_history, auth_manager)
.await
.expect("resume conversation");
// Assert: a Warning event is emitted describing the model mismatch.
let warning = wait_for_event(&conversation, |ev| matches!(ev, EventMsg::Warning(_))).await;
let EventMsg::Warning(WarningEvent { message }) = warning else {
panic!("expected warning event");
};
assert!(message.contains("previous-model"));
assert!(message.contains("current-model"));
// Drain the TaskComplete/Shutdown window to avoid leaking tasks between tests.
// The warning is emitted during initialization, so a short sleep is sufficient.
tokio::time::sleep(Duration::from_millis(50)).await;
}

View File

@@ -1,4 +1,3 @@
#![cfg(not(target_os = "windows"))]
use std::collections::HashMap;
use std::sync::OnceLock;
@@ -151,6 +150,104 @@ fn collect_tool_outputs(bodies: &[Value]) -> Result<HashMap<String, ParsedUnifie
Ok(outputs)
}
fn echo_command(text: &str) -> String {
if cfg!(target_os = "windows") {
format!("Write-Output \"{text}\"")
} else {
format!("/bin/echo {text}")
}
}
fn print_no_newline_command(text: &str) -> String {
if cfg!(target_os = "windows") {
format!("[Console]::Write('{text}')")
} else {
format!("printf '{text}'")
}
}
fn current_dir_command() -> String {
if cfg!(target_os = "windows") {
"[Environment]::CurrentDirectory".to_string()
} else {
"pwd".to_string()
}
}
fn ready_command() -> String {
echo_command("ready")
}
fn cat_like_command() -> String {
if cfg!(target_os = "windows") {
"while (($line = [Console]::In.ReadLine()) -ne $null) { if ($line -eq '__EXIT__') { break }; Write-Output $line }".to_string()
} else {
"/bin/cat".to_string()
}
}
fn cat_exit_input() -> &'static str {
if cfg!(target_os = "windows") {
"__EXIT__\n"
} else {
"\u{0004}"
}
}
fn sleep_then_ready_command() -> String {
if cfg!(target_os = "windows") {
"Start-Sleep -Seconds 0.5; Write-Output 'ready'".to_string()
} else {
"sleep 0.5; echo ready".to_string()
}
}
fn laggy_output_script() -> String {
if cfg!(target_os = "windows") {
concat!(
"$chunk = 'x' * 1048576; ",
"1..4 | ForEach-Object { [Console]::Write($chunk); [Console]::Out.Flush() }; ",
"Start-Sleep -Milliseconds 200; ",
"1..5 | ForEach-Object { Write-Output 'TAIL-MARKER'; Start-Sleep -Milliseconds 50 }; ",
"Start-Sleep -Milliseconds 200",
)
.to_string()
} else {
r#"python3 - <<'PY'
import sys
import time
chunk = b'x' * (1 << 20)
for _ in range(4):
sys.stdout.buffer.write(chunk)
sys.stdout.flush()
time.sleep(0.2)
for _ in range(5):
sys.stdout.write("TAIL-MARKER\n")
sys.stdout.flush()
time.sleep(0.05)
time.sleep(0.2)
PY
"#
.to_string()
}
}
fn large_output_script() -> String {
if cfg!(target_os = "windows") {
"1..300 | ForEach-Object { \"line-$_\" }".to_string()
} else {
r#"python3 - <<'PY'
for i in range(300):
print(f"line-{i}")
PY
"#
.to_string()
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unified_exec_emits_exec_command_begin_event() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -171,7 +268,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> {
let call_id = "uexec-begin-event";
let args = json!({
"cmd": "/bin/echo hello unified exec".to_string(),
"cmd": echo_command("hello unified exec"),
"yield_time_ms": 250,
});
@@ -212,10 +309,8 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> {
})
.await;
assert_eq!(
begin_event.command,
vec!["/bin/echo hello unified exec".to_string()]
);
let expected_command = vec![echo_command("hello unified exec")];
assert_eq!(begin_event.command, expected_command);
assert_eq!(begin_event.cwd, cwd.path());
wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await;
@@ -245,11 +340,17 @@ async fn unified_exec_respects_workdir_override() -> Result<()> {
std::fs::create_dir_all(&workdir)?;
let call_id = "uexec-workdir";
let args = json!({
"cmd": "pwd",
let mut args = json!({
"cmd": current_dir_command(),
"yield_time_ms": 250,
"workdir": workdir.to_string_lossy().to_string(),
});
if cfg!(target_os = "windows")
&& let Some(obj) = args.as_object_mut()
{
obj.insert("shell".to_string(), json!("cmd.exe"));
obj.insert("login".to_string(), json!(false));
}
let responses = vec![
sse(vec![
@@ -297,7 +398,19 @@ async fn unified_exec_respects_workdir_override() -> Result<()> {
.get(call_id)
.expect("missing exec_command workdir output");
let output_text = output.output.trim();
let output_canonical = std::fs::canonicalize(output_text)?;
assert!(
!output_text.is_empty(),
"workdir command should produce a path (raw output: {raw:?}, exit_code: {exit_code:?}, session_id: {session_id:?})",
raw = output.output,
exit_code = output.exit_code,
session_id = output.session_id
);
let output_path = std::path::PathBuf::from(output_text);
assert!(
output_path.exists(),
"workdir output path does not exist: {output_text}"
);
let output_canonical = std::fs::canonicalize(output_path)?;
let expected_canonical = std::fs::canonicalize(&workdir)?;
assert_eq!(
output_canonical, expected_canonical,
@@ -327,7 +440,7 @@ async fn unified_exec_emits_exec_command_end_event() -> Result<()> {
let call_id = "uexec-end-event";
let args = json!({
"cmd": "/bin/echo END-EVENT".to_string(),
"cmd": echo_command("END-EVENT"),
"yield_time_ms": 250,
});
let poll_call_id = "uexec-end-event-poll";
@@ -413,7 +526,7 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> {
let call_id = "uexec-delta-1";
let args = json!({
"cmd": "printf 'HELLO-UEXEC'",
"cmd": print_no_newline_command("HELLO-UEXEC"),
"yield_time_ms": 1000,
});
@@ -484,7 +597,7 @@ async fn unified_exec_emits_output_delta_for_write_stdin() -> Result<()> {
let open_call_id = "uexec-open";
let open_args = json!({
"cmd": "/bin/bash -i",
"cmd": cat_like_command(),
"yield_time_ms": 200,
});
@@ -582,8 +695,9 @@ async fn unified_exec_skips_begin_event_for_empty_input() -> Result<()> {
} = builder.build(&server).await?;
let open_call_id = "uexec-open-session";
let open_cmd = ready_command();
let open_args = json!({
"cmd": "/bin/sh -c echo ready".to_string(),
"cmd": open_cmd,
"yield_time_ms": 250,
});
@@ -654,7 +768,7 @@ async fn unified_exec_skips_begin_event_for_empty_input() -> Result<()> {
"expected only the initial command to emit begin event"
);
assert_eq!(begin_events[0].call_id, open_call_id);
assert_eq!(begin_events[0].command[0], "/bin/sh -c echo ready");
assert_eq!(begin_events[0].command[0], open_cmd);
Ok(())
}
@@ -678,7 +792,7 @@ async fn exec_command_reports_chunk_and_exit_metadata() -> Result<()> {
let call_id = "uexec-metadata";
let args = serde_json::json!({
"cmd": "printf 'abcdefghijklmnopqrstuvwxyz'",
"cmd": print_no_newline_command("abcdefghijklmnopqrstuvwxyz"),
"yield_time_ms": 500,
"max_output_tokens": 6,
});
@@ -788,7 +902,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> {
let exit_call_id = "uexec-cat-exit";
let start_args = serde_json::json!({
"cmd": "/bin/cat",
"cmd": cat_like_command(),
"yield_time_ms": 500,
});
let send_args = serde_json::json!({
@@ -797,7 +911,7 @@ async fn write_stdin_returns_exit_metadata_and_clears_session() -> Result<()> {
"yield_time_ms": 500,
});
let exit_args = serde_json::json!({
"chars": "\u{0004}",
"chars": cat_exit_input(),
"session_id": 0,
"yield_time_ms": 500,
});
@@ -945,7 +1059,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<()
let start_call_id = "uexec-end-on-exit-start";
let start_args = serde_json::json!({
"cmd": "/bin/cat",
"cmd": cat_like_command(),
"yield_time_ms": 200,
});
@@ -958,7 +1072,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<()
let exit_call_id = "uexec-end-on-exit";
let exit_args = serde_json::json!({
"chars": "\u{0004}",
"chars": cat_exit_input(),
"session_id": 0,
"yield_time_ms": 500,
});
@@ -1048,7 +1162,7 @@ async fn unified_exec_reuses_session_via_stdin() -> Result<()> {
let first_call_id = "uexec-start";
let first_args = serde_json::json!({
"cmd": "/bin/cat",
"cmd": cat_like_command(),
"yield_time_ms": 200,
});
@@ -1155,24 +1269,7 @@ async fn unified_exec_streams_after_lagged_output() -> Result<()> {
..
} = builder.build(&server).await?;
let script = r#"python3 - <<'PY'
import sys
import time
chunk = b'x' * (1 << 20)
for _ in range(4):
sys.stdout.buffer.write(chunk)
sys.stdout.flush()
time.sleep(0.2)
for _ in range(5):
sys.stdout.write("TAIL-MARKER\n")
sys.stdout.flush()
time.sleep(0.05)
time.sleep(0.2)
PY
"#;
let script = laggy_output_script();
let first_call_id = "uexec-lag-start";
let first_args = serde_json::json!({
@@ -1282,7 +1379,7 @@ async fn unified_exec_timeout_and_followup_poll() -> Result<()> {
let first_call_id = "uexec-timeout";
let first_args = serde_json::json!({
"cmd": "sleep 0.5; echo ready",
"cmd": sleep_then_ready_command(),
"yield_time_ms": 10,
});
@@ -1386,11 +1483,7 @@ async fn unified_exec_formats_large_output_summary() -> Result<()> {
..
} = builder.build(&server).await?;
let script = r#"python3 - <<'PY'
for i in range(300):
print(f"line-{i}")
PY
"#;
let script = large_output_script();
let call_id = "uexec-large-output";
let args = serde_json::json!({
@@ -1473,7 +1566,7 @@ async fn unified_exec_runs_under_sandbox() -> Result<()> {
let call_id = "uexec";
let args = serde_json::json!({
"cmd": "echo 'hello'",
"cmd": echo_command("hello"),
"yield_time_ms": 500,
});

12
codex-rs/patch.txt Normal file
View File

@@ -0,0 +1,12 @@
*** Begin Patch
*** Update File: core\tests\suite\unified_exec.rs
@@
fn current_dir_command() -> String {
if cfg!(target_os = "windows") {
- "[Environment]::CurrentDirectory".to_string()
+ "cd".to_string()
} else {
"pwd".to_string()
}
}
*** End Patch

View File

@@ -292,7 +292,7 @@ impl From<Vec<UserInput>> for ResponseInputItem {
}
/// If the `name` of a `ResponseItem::FunctionCall` is either `container.exec`
/// or shell`, the `arguments` field should deserialize to this struct.
/// or `shell`, the `arguments` field should deserialize to this struct.
#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
pub struct ShellToolCallParams {
pub command: Vec<String>,
@@ -307,6 +307,22 @@ pub struct ShellToolCallParams {
pub justification: Option<String>,
}
/// If the `name` of a `ResponseItem::FunctionCall` is `shell_command`, the
/// `arguments` field should deserialize to this struct.
#[derive(Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
pub struct ShellCommandToolCallParams {
pub command: String,
pub workdir: Option<String>,
/// This is the maximum time in milliseconds that the command is allowed to run.
#[serde(alias = "timeout")]
pub timeout_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub with_escalated_permissions: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub justification: Option<String>,
}
/// Responses API compatible content items that can be returned by a tool call.
/// This is a subset of ContentItem with the types we support as function call outputs.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]

View File

@@ -1709,6 +1709,7 @@ impl ChatWidget {
};
self.add_to_history(crate::status::new_status_output(
&self.config,
self.auth_manager.as_ref(),
total_usage,
context_usage,
&self.conversation_id,

View File

@@ -33,6 +33,7 @@ use super::rate_limits::format_status_limit_summary;
use super::rate_limits::render_status_limit_progress_bar;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_lines;
use codex_core::AuthManager;
#[derive(Debug, Clone)]
struct StatusContextWindowData {
@@ -65,6 +66,7 @@ struct StatusHistoryCell {
pub(crate) fn new_status_output(
config: &Config,
auth_manager: &AuthManager,
total_usage: &TokenUsage,
context_usage: Option<&TokenUsage>,
session_id: &Option<ConversationId>,
@@ -74,6 +76,7 @@ pub(crate) fn new_status_output(
let command = PlainHistoryCell::new(vec!["/status".magenta().into()]);
let card = StatusHistoryCell::new(
config,
auth_manager,
total_usage,
context_usage,
session_id,
@@ -87,6 +90,7 @@ pub(crate) fn new_status_output(
impl StatusHistoryCell {
fn new(
config: &Config,
auth_manager: &AuthManager,
total_usage: &TokenUsage,
context_usage: Option<&TokenUsage>,
session_id: &Option<ConversationId>,
@@ -106,7 +110,7 @@ impl StatusHistoryCell {
SandboxPolicy::WorkspaceWrite { .. } => "workspace-write".to_string(),
};
let agents_summary = compose_agents_summary(config);
let account = compose_account_display(config);
let account = compose_account_display(auth_manager);
let session_id = session_id.as_ref().map(std::string::ToString::to_string);
let context_window = config.model_context_window.and_then(|window| {
context_usage.map(|usage| StatusContextWindowData {

View File

@@ -2,7 +2,8 @@ use crate::exec_command::relativize_to_home;
use crate::text_formatting;
use chrono::DateTime;
use chrono::Local;
use codex_core::auth::load_auth_dot_json;
use codex_app_server_protocol::AuthMode;
use codex_core::AuthManager;
use codex_core::config::Config;
use codex_core::project_doc::discover_project_doc_paths;
use std::path::Path;
@@ -82,24 +83,17 @@ pub(crate) fn compose_agents_summary(config: &Config) -> String {
}
}
pub(crate) fn compose_account_display(config: &Config) -> Option<StatusAccountDisplay> {
let auth =
load_auth_dot_json(&config.codex_home, config.cli_auth_credentials_store_mode).ok()??;
pub(crate) fn compose_account_display(auth_manager: &AuthManager) -> Option<StatusAccountDisplay> {
let auth = auth_manager.auth()?;
if let Some(tokens) = auth.tokens.as_ref() {
let info = &tokens.id_token;
let email = info.email.clone();
let plan = info.get_chatgpt_plan_type().as_deref().map(title_case);
return Some(StatusAccountDisplay::ChatGpt { email, plan });
match auth.mode {
AuthMode::ChatGPT => {
let email = auth.get_account_email();
let plan = auth.raw_plan_type().map(|plan| title_case(plan.as_str()));
Some(StatusAccountDisplay::ChatGpt { email, plan })
}
AuthMode::ApiKey => Some(StatusAccountDisplay::ApiKey),
}
if let Some(key) = auth.openai_api_key
&& !key.is_empty()
{
return Some(StatusAccountDisplay::ApiKey);
}
None
}
pub(crate) fn format_tokens_compact(value: i64) -> String {

View File

@@ -4,6 +4,7 @@ use crate::history_cell::HistoryCell;
use chrono::Duration as ChronoDuration;
use chrono::TimeZone;
use chrono::Utc;
use codex_core::AuthManager;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
@@ -27,6 +28,14 @@ fn test_config(temp_home: &TempDir) -> Config {
.expect("load config")
}
fn test_auth_manager(config: &Config) -> AuthManager {
AuthManager::new(
config.codex_home.clone(),
false,
config.cli_auth_credentials_store_mode,
)
}
fn render_lines(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
@@ -85,6 +94,7 @@ fn status_snapshot_includes_reasoning_details() {
config.cwd = PathBuf::from("/workspace/tests");
let auth_manager = test_auth_manager(&config);
let usage = TokenUsage {
input_tokens: 1_200,
cached_input_tokens: 200,
@@ -113,6 +123,7 @@ fn status_snapshot_includes_reasoning_details() {
let composite = new_status_output(
&config,
&auth_manager,
&usage,
Some(&usage),
&None,
@@ -137,6 +148,7 @@ fn status_snapshot_includes_monthly_limit() {
config.model_provider_id = "openai".to_string();
config.cwd = PathBuf::from("/workspace/tests");
let auth_manager = test_auth_manager(&config);
let usage = TokenUsage {
input_tokens: 800,
cached_input_tokens: 0,
@@ -161,6 +173,7 @@ fn status_snapshot_includes_monthly_limit() {
let composite = new_status_output(
&config,
&auth_manager,
&usage,
Some(&usage),
&None,
@@ -184,6 +197,7 @@ fn status_card_token_usage_excludes_cached_tokens() {
config.model = "gpt-5-codex".to_string();
config.cwd = PathBuf::from("/workspace/tests");
let auth_manager = test_auth_manager(&config);
let usage = TokenUsage {
input_tokens: 1_200,
cached_input_tokens: 200,
@@ -197,7 +211,15 @@ fn status_card_token_usage_excludes_cached_tokens() {
.single()
.expect("timestamp");
let composite = new_status_output(&config, &usage, Some(&usage), &None, None, now);
let composite = new_status_output(
&config,
&auth_manager,
&usage,
Some(&usage),
&None,
None,
now,
);
let rendered = render_lines(&composite.display_lines(120));
assert!(
@@ -216,6 +238,7 @@ fn status_snapshot_truncates_in_narrow_terminal() {
config.model_reasoning_summary = ReasoningSummary::Detailed;
config.cwd = PathBuf::from("/workspace/tests");
let auth_manager = test_auth_manager(&config);
let usage = TokenUsage {
input_tokens: 1_200,
cached_input_tokens: 200,
@@ -240,6 +263,7 @@ fn status_snapshot_truncates_in_narrow_terminal() {
let composite = new_status_output(
&config,
&auth_manager,
&usage,
Some(&usage),
&None,
@@ -264,6 +288,7 @@ fn status_snapshot_shows_missing_limits_message() {
config.model = "gpt-5-codex".to_string();
config.cwd = PathBuf::from("/workspace/tests");
let auth_manager = test_auth_manager(&config);
let usage = TokenUsage {
input_tokens: 500,
cached_input_tokens: 0,
@@ -277,7 +302,15 @@ fn status_snapshot_shows_missing_limits_message() {
.single()
.expect("timestamp");
let composite = new_status_output(&config, &usage, Some(&usage), &None, None, now);
let composite = new_status_output(
&config,
&auth_manager,
&usage,
Some(&usage),
&None,
None,
now,
);
let mut rendered_lines = render_lines(&composite.display_lines(80));
if cfg!(windows) {
for line in &mut rendered_lines {
@@ -295,6 +328,7 @@ fn status_snapshot_shows_empty_limits_message() {
config.model = "gpt-5-codex".to_string();
config.cwd = PathBuf::from("/workspace/tests");
let auth_manager = test_auth_manager(&config);
let usage = TokenUsage {
input_tokens: 500,
cached_input_tokens: 0,
@@ -315,6 +349,7 @@ fn status_snapshot_shows_empty_limits_message() {
let composite = new_status_output(
&config,
&auth_manager,
&usage,
Some(&usage),
&None,
@@ -338,6 +373,7 @@ fn status_snapshot_shows_stale_limits_message() {
config.model = "gpt-5-codex".to_string();
config.cwd = PathBuf::from("/workspace/tests");
let auth_manager = test_auth_manager(&config);
let usage = TokenUsage {
input_tokens: 1_200,
cached_input_tokens: 200,
@@ -367,6 +403,7 @@ fn status_snapshot_shows_stale_limits_message() {
let composite = new_status_output(
&config,
&auth_manager,
&usage,
Some(&usage),
&None,
@@ -389,6 +426,7 @@ fn status_context_window_uses_last_usage() {
let mut config = test_config(&temp_home);
config.model_context_window = Some(272_000);
let auth_manager = test_auth_manager(&config);
let total_usage = TokenUsage {
input_tokens: 12_800,
cached_input_tokens: 0,
@@ -409,7 +447,15 @@ fn status_context_window_uses_last_usage() {
.single()
.expect("timestamp");
let composite = new_status_output(&config, &total_usage, Some(&last_usage), &None, None, now);
let composite = new_status_output(
&config,
&auth_manager,
&total_usage,
Some(&last_usage),
&None,
None,
now,
);
let rendered_lines = render_lines(&composite.display_lines(80));
let context_line = rendered_lines
.into_iter()

View File

@@ -16,8 +16,8 @@ use tokio::sync::oneshot;
use tokio::sync::Mutex as TokioMutex;
use tokio::task::JoinHandle;
#[derive(Debug)]
pub struct ExecCommandSession {
master: Box<dyn portable_pty::MasterPty + Send>,
writer_tx: mpsc::Sender<Vec<u8>>,
output_tx: broadcast::Sender<Vec<u8>>,
killer: StdMutex<Option<Box<dyn portable_pty::ChildKiller + Send + Sync>>>,
@@ -28,9 +28,19 @@ pub struct ExecCommandSession {
exit_code: Arc<StdMutex<Option<i32>>>,
}
impl std::fmt::Debug for ExecCommandSession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ExecCommandSession")
.field("exit_status", &self.exit_status)
.field("exit_code", &self.exit_code)
.finish()
}
}
impl ExecCommandSession {
#[allow(clippy::too_many_arguments)]
pub fn new(
master: Box<dyn portable_pty::MasterPty + Send>,
writer_tx: mpsc::Sender<Vec<u8>>,
output_tx: broadcast::Sender<Vec<u8>>,
killer: Box<dyn portable_pty::ChildKiller + Send + Sync>,
@@ -43,6 +53,7 @@ impl ExecCommandSession {
let initial_output_rx = output_tx.subscribe();
(
Self {
master,
writer_tx,
output_tx,
killer: StdMutex::new(Some(killer)),
@@ -125,9 +136,22 @@ pub async fn spawn_pty_process(
pixel_height: 0,
})?;
let mut command_builder = CommandBuilder::new(arg0.as_ref().unwrap_or(&program.to_string()));
let master = pair.master;
let mut slave = pair.slave;
let mut command_builder = CommandBuilder::new(program);
let _ = arg0;
command_builder.cwd(cwd);
#[cfg(not(target_os = "windows"))]
command_builder.env_clear();
#[cfg(target_os = "windows")]
{
// Keep the inherited Windows environment to avoid missing critical
// variables that cause console hosts to fail to initialize.
for (key, value) in std::env::vars() {
command_builder.env(key, value);
}
}
for arg in args {
command_builder.arg(arg);
}
@@ -135,13 +159,33 @@ pub async fn spawn_pty_process(
command_builder.env(key, value);
}
let mut child = pair.slave.spawn_command(command_builder)?;
#[cfg(all(test, target_os = "windows"))]
eprintln!(
"spawn_pty_process env keys: {:?}",
env.keys().cloned().collect::<Vec<_>>()
);
#[cfg(target_os = "windows")]
{
// Ensure core OS variables are present even if the provided env map
// was minimized.
for key in ["SystemRoot", "WINDIR", "COMSPEC", "PATHEXT", "PATH"] {
if !env.contains_key(key) {
if let Ok(value) = std::env::var(key) {
command_builder.env(key, value);
}
}
}
}
let mut child = slave.spawn_command(command_builder)?;
drop(slave);
let killer = child.clone_killer();
let (writer_tx, mut writer_rx) = mpsc::channel::<Vec<u8>>(128);
let (output_tx, _) = broadcast::channel::<Vec<u8>>(256);
let mut reader = pair.master.try_clone_reader()?;
let mut reader = master.try_clone_reader()?;
let output_tx_clone = output_tx.clone();
let reader_handle: JoinHandle<()> = tokio::task::spawn_blocking(move || {
let mut buf = [0u8; 8_192];
@@ -161,7 +205,7 @@ pub async fn spawn_pty_process(
}
});
let writer = pair.master.take_writer()?;
let writer = master.take_writer()?;
let writer = Arc::new(TokioMutex::new(writer));
let writer_handle: JoinHandle<()> = tokio::spawn({
let writer = Arc::clone(&writer);
@@ -193,6 +237,7 @@ pub async fn spawn_pty_process(
});
let (session, output_rx) = ExecCommandSession::new(
master,
writer_tx,
output_tx,
killer,
@@ -209,3 +254,87 @@ pub async fn spawn_pty_process(
exit_rx,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[tokio::test]
#[cfg(target_os = "windows")]
async fn spawn_cmd_succeeds() {
let mut env: HashMap<String, String> = std::env::vars().collect();
if let Some(system_root) = env.get("SystemRoot").cloned() {
let base_paths = vec![
format!(r"{system_root}\system32"),
system_root.clone(),
format!(r"{system_root}\System32\Wbem"),
format!(r"{system_root}\System32\WindowsPowerShell\v1.0"),
];
env.insert("PATH".to_string(), base_paths.join(";"));
}
let cwd = std::env::current_dir().expect("current_dir");
eprintln!(
"SystemRoot={:?} ComSpec={:?} PATH={:?}",
env.get("SystemRoot"),
env.get("ComSpec"),
env.get("PATH")
.map(|p| p.split(';').take(3).collect::<Vec<_>>())
);
let comspec = std::env::var("ComSpec").unwrap_or_else(|_| "cmd.exe".to_string());
let mut spawned = spawn_pty_process(
&comspec,
&["/C".to_string(), "exit 0".to_string()],
&cwd,
&env,
&None,
)
.await
.expect("spawn cmd");
let mut output_rx = spawned.output_rx;
let first_chunk = output_rx.try_recv().ok();
eprintln!(
"first_chunk = {:?}",
first_chunk
.as_ref()
.map(|bytes| String::from_utf8_lossy(bytes))
);
let status = spawned.exit_rx.await.expect("exit status");
assert_eq!(status, 0, "cmd.exe should exit successfully");
// Drain any output to avoid broadcast warnings.
while output_rx.try_recv().is_ok() {}
}
#[test]
#[cfg(target_os = "windows")]
fn spawn_cmd_blocking() {
let pty_system = native_pty_system();
let mut pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.expect("open pty");
let mut cmd = CommandBuilder::new(
std::env::var("ComSpec").unwrap_or_else(|_| "C:\\windows\\system32\\cmd.exe".into()),
);
cmd.arg("/C");
cmd.arg("exit 0");
let mut child = pair.slave.spawn_command(cmd).expect("spawn blocking cmd");
drop(pair.slave);
// Explicitly close stdin so the child can exit cleanly.
drop(pair.master.take_writer().expect("writer"));
let status = child.wait().expect("wait for child");
assert_eq!(status.exit_code(), 0, "cmd.exe exit code");
}
}