Compare commits

...

1 Commits

Author SHA1 Message Date
Ben Schoepke
31a9c54dba initial vibe code 2026-03-03 10:44:24 -08:00
18 changed files with 3027 additions and 0 deletions

View File

@@ -379,6 +379,9 @@
"prevent_idle_sleep": {
"type": "boolean"
},
"ps_repl": {
"type": "boolean"
},
"realtime_conversation": {
"type": "boolean"
},
@@ -502,6 +505,14 @@
"plan_mode_reasoning_effort": {
"$ref": "#/definitions/ReasoningEffort"
},
"ps_repl_path": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Optional absolute path to the PowerShell 7 runtime used by `ps_repl`."
},
"sandbox_mode": {
"$ref": "#/definitions/SandboxMode"
},
@@ -1750,6 +1761,9 @@
"prevent_idle_sleep": {
"type": "boolean"
},
"ps_repl": {
"type": "boolean"
},
"realtime_conversation": {
"type": "boolean"
},
@@ -2088,6 +2102,14 @@
},
"type": "object"
},
"ps_repl_path": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Optional absolute path to the PowerShell 7 runtime used by `ps_repl`."
},
"review_model": {
"description": "Review model override used by the `/review` feature.",
"type": "string"

View File

@@ -88,6 +88,7 @@ pub(crate) async fn apply_role_to_config(
cwd: Some(config.cwd.clone()),
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(),
ps_repl_path: config.ps_repl_path.clone(),
js_repl_node_path: config.js_repl_node_path.clone(),
..Default::default()
},

View File

@@ -277,6 +277,8 @@ use crate::tools::network_approval::NetworkApprovalService;
use crate::tools::network_approval::build_blocked_request_observer;
use crate::tools::network_approval::build_network_policy_decider;
use crate::tools::parallel::ToolCallRuntime;
use crate::tools::ps_repl::PsReplHandle;
use crate::tools::ps_repl::resolve_compatible_pwsh;
use crate::tools::sandboxing::ApprovalStore;
use crate::tools::spec::ToolsConfig;
use crate::tools::spec::ToolsConfigParams;
@@ -378,6 +380,16 @@ impl Codex {
config.features.disable(Feature::JsReplToolsOnly);
config.startup_warnings.push(message);
}
if config.features.enabled(Feature::PsRepl)
&& let Err(err) = resolve_compatible_pwsh(config.ps_repl_path.as_deref()).await
{
let message = format!(
"Disabled `ps_repl` for this session because the configured PowerShell runtime is unavailable or incompatible. {err}"
);
warn!("{message}");
config.features.disable(Feature::PsRepl);
config.startup_warnings.push(message);
}
let allowed_skills_for_implicit_invocation =
loaded_skills.allowed_skills_for_implicit_invocation();
@@ -608,6 +620,7 @@ pub(crate) struct Session {
pub(crate) conversation: Arc<RealtimeConversationManager>,
pub(crate) active_turn: Mutex<Option<ActiveTurn>>,
pub(crate) services: SessionServices,
ps_repl: Arc<PsReplHandle>,
js_repl: Arc<JsReplHandle>,
next_internal_sub_id: AtomicU64,
}
@@ -663,6 +676,7 @@ pub(crate) struct TurnContext {
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
pub(crate) tool_call_gate: Arc<ReadinessFlag>,
pub(crate) truncation_policy: TruncationPolicy,
pub(crate) ps_repl: Arc<PsReplHandle>,
pub(crate) js_repl: Arc<JsReplHandle>,
pub(crate) dynamic_tools: Vec<DynamicToolSpec>,
pub(crate) turn_metadata_state: Arc<TurnMetadataState>,
@@ -751,6 +765,7 @@ impl TurnContext {
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(),
tool_call_gate: Arc::new(ReadinessFlag::new()),
truncation_policy,
ps_repl: Arc::clone(&self.ps_repl),
js_repl: Arc::clone(&self.js_repl),
dynamic_tools: self.dynamic_tools.clone(),
turn_metadata_state: self.turn_metadata_state.clone(),
@@ -1056,6 +1071,7 @@ impl Session {
model_info: ModelInfo,
network: Option<NetworkProxy>,
sub_id: String,
ps_repl: Arc<PsReplHandle>,
js_repl: Arc<JsReplHandle>,
skills_outcome: Arc<SkillLoadOutcome>,
) -> TurnContext {
@@ -1125,6 +1141,7 @@ impl Session {
codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(),
tool_call_gate: Arc::new(ReadinessFlag::new()),
truncation_policy: model_info.truncation_policy.into(),
ps_repl,
js_repl,
dynamic_tools: session_configuration.dynamic_tools.clone(),
turn_metadata_state,
@@ -1519,6 +1536,7 @@ impl Session {
config.js_repl_node_path.clone(),
config.js_repl_node_module_dirs.clone(),
));
let ps_repl = Arc::new(PsReplHandle::with_pwsh_path(config.ps_repl_path.clone()));
let sess = Arc::new(Session {
conversation_id,
@@ -1530,6 +1548,7 @@ impl Session {
conversation: Arc::new(RealtimeConversationManager::new()),
active_turn: Mutex::new(None),
services,
ps_repl,
js_repl,
next_internal_sub_id: AtomicU64::new(0),
});
@@ -2167,6 +2186,7 @@ impl Session {
.as_ref()
.map(StartedNetworkProxy::proxy),
sub_id,
Arc::clone(&self.ps_repl),
Arc::clone(&self.js_repl),
skills_outcome,
);
@@ -4695,6 +4715,7 @@ async fn spawn_review_thread(
final_output_json_schema: None,
codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(),
tool_call_gate: Arc::new(ReadinessFlag::new()),
ps_repl: Arc::clone(&sess.ps_repl),
js_repl: Arc::clone(&sess.js_repl),
dynamic_tools: parent_turn_context.dynamic_tools.clone(),
truncation_policy: model_info.truncation_policy.into(),
@@ -8334,6 +8355,7 @@ mod tests {
config.js_repl_node_path.clone(),
config.js_repl_node_module_dirs.clone(),
));
let ps_repl = Arc::new(PsReplHandle::with_pwsh_path(config.ps_repl_path.clone()));
let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config));
let turn_context = Session::make_turn_context(
@@ -8345,6 +8367,7 @@ mod tests {
model_info,
None,
"turn_id".to_string(),
Arc::clone(&ps_repl),
Arc::clone(&js_repl),
skills_outcome,
);
@@ -8359,6 +8382,7 @@ mod tests {
conversation: Arc::new(RealtimeConversationManager::new()),
active_turn: Mutex::new(None),
services,
ps_repl,
js_repl,
next_internal_sub_id: AtomicU64::new(0),
};
@@ -8502,6 +8526,7 @@ mod tests {
config.js_repl_node_path.clone(),
config.js_repl_node_module_dirs.clone(),
));
let ps_repl = Arc::new(PsReplHandle::with_pwsh_path(config.ps_repl_path.clone()));
let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config));
let turn_context = Arc::new(Session::make_turn_context(
@@ -8513,6 +8538,7 @@ mod tests {
model_info,
None,
"turn_id".to_string(),
Arc::clone(&ps_repl),
Arc::clone(&js_repl),
skills_outcome,
));
@@ -8527,6 +8553,7 @@ mod tests {
conversation: Arc::new(RealtimeConversationManager::new()),
active_turn: Mutex::new(None),
services,
ps_repl,
js_repl,
next_internal_sub_id: AtomicU64::new(0),
});

View File

@@ -394,6 +394,9 @@ pub struct Config {
/// code via [`ConfigOverrides`].
pub main_execve_wrapper_exe: Option<PathBuf>,
/// Optional absolute path to the PowerShell 7 runtime used by `ps_repl`.
pub ps_repl_path: Option<PathBuf>,
/// Optional absolute path to the Node runtime used by `js_repl`.
pub js_repl_node_path: Option<PathBuf>,
@@ -1121,6 +1124,9 @@ pub struct ConfigToml {
/// Default: `300000` (5 minutes).
pub background_terminal_max_timeout: Option<u64>,
/// Optional absolute path to the PowerShell 7 runtime used by `ps_repl`.
pub ps_repl_path: Option<AbsolutePathBuf>,
/// Optional absolute path to the Node runtime used by `js_repl`.
pub js_repl_node_path: Option<AbsolutePathBuf>,
@@ -1553,6 +1559,7 @@ pub struct ConfigOverrides {
pub config_profile: Option<String>,
pub codex_linux_sandbox_exe: Option<PathBuf>,
pub main_execve_wrapper_exe: Option<PathBuf>,
pub ps_repl_path: Option<PathBuf>,
pub js_repl_node_path: Option<PathBuf>,
pub js_repl_node_module_dirs: Option<Vec<PathBuf>>,
pub zsh_path: Option<PathBuf>,
@@ -1683,6 +1690,7 @@ impl Config {
config_profile: config_profile_key,
codex_linux_sandbox_exe,
main_execve_wrapper_exe,
ps_repl_path: ps_repl_path_override,
js_repl_node_path: js_repl_node_path_override,
js_repl_node_module_dirs: js_repl_node_module_dirs_override,
zsh_path: zsh_path_override,
@@ -1981,6 +1989,9 @@ impl Config {
"experimental compact prompt file",
)?;
let compact_prompt = compact_prompt.or(file_compact_prompt);
let ps_repl_path = ps_repl_path_override
.or(config_profile.ps_repl_path.map(Into::into))
.or(cfg.ps_repl_path.map(Into::into));
let js_repl_node_path = js_repl_node_path_override
.or(config_profile.js_repl_node_path.map(Into::into))
.or(cfg.js_repl_node_path.map(Into::into));
@@ -2150,6 +2161,7 @@ impl Config {
file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
codex_linux_sandbox_exe,
main_execve_wrapper_exe,
ps_repl_path,
js_repl_node_path,
js_repl_node_module_dirs,
zsh_path,
@@ -4909,6 +4921,7 @@ model_verbosity = "high"
file_opener: UriBasedFileOpener::VsCode,
codex_linux_sandbox_exe: None,
main_execve_wrapper_exe: None,
ps_repl_path: None,
js_repl_node_path: None,
js_repl_node_module_dirs: Vec::new(),
zsh_path: None,
@@ -5037,6 +5050,7 @@ model_verbosity = "high"
file_opener: UriBasedFileOpener::VsCode,
codex_linux_sandbox_exe: None,
main_execve_wrapper_exe: None,
ps_repl_path: None,
js_repl_node_path: None,
js_repl_node_module_dirs: Vec::new(),
zsh_path: None,
@@ -5163,6 +5177,7 @@ model_verbosity = "high"
file_opener: UriBasedFileOpener::VsCode,
codex_linux_sandbox_exe: None,
main_execve_wrapper_exe: None,
ps_repl_path: None,
js_repl_node_path: None,
js_repl_node_module_dirs: Vec::new(),
zsh_path: None,
@@ -5275,6 +5290,7 @@ model_verbosity = "high"
file_opener: UriBasedFileOpener::VsCode,
codex_linux_sandbox_exe: None,
main_execve_wrapper_exe: None,
ps_repl_path: None,
js_repl_node_path: None,
js_repl_node_module_dirs: Vec::new(),
zsh_path: None,

View File

@@ -33,6 +33,8 @@ pub struct ConfigProfile {
pub chatgpt_base_url: Option<String>,
/// Optional path to a file containing model instructions.
pub model_instructions_file: Option<AbsolutePathBuf>,
/// Optional absolute path to the PowerShell 7 runtime used by `ps_repl`.
pub ps_repl_path: Option<AbsolutePathBuf>,
pub js_repl_node_path: Option<AbsolutePathBuf>,
/// Ordered list of directories to search for Node modules in `js_repl`.
pub js_repl_node_module_dirs: Option<Vec<AbsolutePathBuf>>,

View File

@@ -78,6 +78,8 @@ pub enum Feature {
ShellTool,
// Experimental
/// Enable PowerShell REPL tools backed by a persistent pwsh kernel.
PsRepl,
/// Enable JavaScript REPL tools backed by a persistent Node kernel.
JsRepl,
/// Only expose js_repl tools directly to the model.
@@ -460,6 +462,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::PsRepl,
key: "ps_repl",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::JsRepl,
key: "js_repl",
@@ -825,6 +833,12 @@ mod tests {
assert_eq!(Feature::JsRepl.default_enabled(), false);
}
#[test]
fn ps_repl_is_under_development_and_disabled_by_default() {
assert_eq!(Feature::PsRepl.stage(), Stage::UnderDevelopment);
assert_eq!(Feature::PsRepl.default_enabled(), false);
}
#[test]
fn collab_is_legacy_alias_for_multi_agent() {
assert_eq!(feature_for_key("multi_agent"), Some(Feature::Collab));

View File

@@ -69,6 +69,25 @@ fn render_js_repl_instructions(config: &Config) -> Option<String> {
Some(section)
}
fn render_ps_repl_instructions(config: &Config) -> Option<String> {
if !config.features.enabled(Feature::PsRepl) {
return None;
}
let mut section = String::from("## PowerShell REPL (pwsh)\n");
section.push_str(
"- Use `ps_repl` for PowerShell-backed automation in a persistent `pwsh` kernel.\n",
);
section.push_str("- `ps_repl` is a freeform/custom tool. Direct `ps_repl` calls must send raw PowerShell input (optionally with first-line `# codex-ps-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n");
section.push_str("- Helpers: `$CodexTmpDir`, `Invoke-CodexTool -Name <string> -Arguments <object|string>`, `$Codex.TmpDir`, and `$Codex.Tool(<name>, <args>)`.\n");
section.push_str("- `Invoke-CodexTool` returns the raw tool output object. Use it for built-in tools, dynamic tools, and MCP tools.\n");
section.push_str("- PowerShell session state persists across calls, including variables, functions, aliases, imported modules, environment changes, and `$LASTEXITCODE`. Reset the kernel with `ps_repl_reset` when needed.\n");
section.push_str("- To share generated images with the model, write a file under `$CodexTmpDir`, call `Invoke-CodexTool -Name view_image -Arguments @{ path = \"/absolute/path\" }`, then delete the file.\n");
section.push_str("- Avoid direct `[Console]::Write*`, raw StdOut/StdErr writes, or other host-level output that bypasses PowerShell streams; they can corrupt the JSON line protocol. Use pipeline output, `Write-Output`, `Write-Host`, `Write-Verbose`, or `Write-Warning` instead.");
Some(section)
}
/// Combines `Config::instructions` and `AGENTS.md` (if present) into a single
/// string of instructions.
pub(crate) async fn get_user_instructions(
@@ -103,6 +122,13 @@ pub(crate) async fn get_user_instructions(
output.push_str(&js_repl_section);
}
if let Some(ps_repl_section) = render_ps_repl_instructions(config) {
if !output.is_empty() {
output.push_str("\n\n");
}
output.push_str(&ps_repl_section);
}
let skills_section = skills.and_then(render_skills_section);
if let Some(skills_section) = skills_section {
if !output.is_empty() {
@@ -492,6 +518,19 @@ mod tests {
assert_eq!(res, expected);
}
#[tokio::test]
async fn ps_repl_instructions_are_appended_when_enabled() {
let tmp = tempfile::tempdir().expect("tempdir");
let mut cfg = make_config(&tmp, 4096, None).await;
cfg.features.enable(Feature::PsRepl);
let res = get_user_instructions(&cfg, None)
.await
.expect("ps_repl instructions expected");
let expected = "## PowerShell REPL (pwsh)\n- Use `ps_repl` for PowerShell-backed automation in a persistent `pwsh` kernel.\n- `ps_repl` is a freeform/custom tool. Direct `ps_repl` calls must send raw PowerShell input (optionally with first-line `# codex-ps-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `$CodexTmpDir`, `Invoke-CodexTool -Name <string> -Arguments <object|string>`, `$Codex.TmpDir`, and `$Codex.Tool(<name>, <args>)`.\n- `Invoke-CodexTool` returns the raw tool output object. Use it for built-in tools, dynamic tools, and MCP tools.\n- PowerShell session state persists across calls, including variables, functions, aliases, imported modules, environment changes, and `$LASTEXITCODE`. Reset the kernel with `ps_repl_reset` when needed.\n- To share generated images with the model, write a file under `$CodexTmpDir`, call `Invoke-CodexTool -Name view_image -Arguments @{ path = \"/absolute/path\" }`, then delete the file.\n- Avoid direct `[Console]::Write*`, raw StdOut/StdErr writes, or other host-level output that bypasses PowerShell streams; they can corrupt the JSON line protocol. Use pipeline output, `Write-Output`, `Write-Host`, `Write-Verbose`, or `Write-Warning` instead.";
assert_eq!(res, expected);
}
/// When both system instructions *and* a project doc are present the two
/// should be concatenated with the separator.
#[tokio::test]

View File

@@ -20,6 +20,7 @@ pub type SharedTurnDiffTracker = Arc<Mutex<TurnDiffTracker>>;
pub enum ToolCallSource {
Direct,
JsRepl,
PsRepl,
}
#[derive(Clone)]

View File

@@ -8,6 +8,7 @@ mod mcp;
mod mcp_resource;
pub(crate) mod multi_agents;
mod plan;
mod ps_repl;
mod read_file;
mod request_user_input;
mod search_tool_bm25;
@@ -38,6 +39,8 @@ pub use mcp::McpHandler;
pub use mcp_resource::McpResourceHandler;
pub use multi_agents::MultiAgentHandler;
pub use plan::PlanHandler;
pub use ps_repl::PsReplHandler;
pub use ps_repl::PsReplResetHandler;
pub use read_file::ReadFileHandler;
pub use request_user_input::RequestUserInputHandler;
pub(crate) use request_user_input::request_user_input_tool_description;

View File

@@ -0,0 +1,363 @@
use async_trait::async_trait;
use serde_json::Value as JsonValue;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use crate::exec::ExecToolCallOutput;
use crate::exec::StreamOutput;
use crate::features::Feature;
use crate::function_tool::FunctionCallError;
use crate::protocol::ExecCommandSource;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::events::ToolEmitter;
use crate::tools::events::ToolEventCtx;
use crate::tools::events::ToolEventFailure;
use crate::tools::events::ToolEventStage;
use crate::tools::handlers::parse_arguments;
use crate::tools::ps_repl::PS_REPL_PRAGMA_PREFIX;
use crate::tools::ps_repl::PsExecResult;
use crate::tools::ps_repl::PsReplArgs;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputContentItem;
pub struct PsReplHandler;
pub struct PsReplResetHandler;
fn join_outputs(stdout: &str, stderr: &str) -> String {
if stdout.is_empty() {
stderr.to_string()
} else if stderr.is_empty() {
stdout.to_string()
} else {
format!("{stdout}\n{stderr}")
}
}
fn build_ps_repl_exec_output(
output: &str,
error: Option<&str>,
duration: Duration,
) -> ExecToolCallOutput {
let stdout = output.to_string();
let stderr = error.unwrap_or("").to_string();
let aggregated_output = join_outputs(&stdout, &stderr);
ExecToolCallOutput {
exit_code: if error.is_some() { 1 } else { 0 },
stdout: StreamOutput::new(stdout),
stderr: StreamOutput::new(stderr),
aggregated_output: StreamOutput::new(aggregated_output),
duration,
timed_out: false,
}
}
async fn emit_ps_repl_exec_begin(
session: &crate::codex::Session,
turn: &crate::codex::TurnContext,
call_id: &str,
) {
let emitter = ToolEmitter::shell(
vec!["ps_repl".to_string()],
turn.cwd.clone(),
ExecCommandSource::Agent,
false,
);
let ctx = ToolEventCtx::new(session, turn, call_id, None);
emitter.emit(ctx, ToolEventStage::Begin).await;
}
async fn emit_ps_repl_exec_end(
session: &crate::codex::Session,
turn: &crate::codex::TurnContext,
call_id: &str,
output: &str,
error: Option<&str>,
duration: Duration,
) {
let exec_output = build_ps_repl_exec_output(output, error, duration);
let emitter = ToolEmitter::shell(
vec!["ps_repl".to_string()],
turn.cwd.clone(),
ExecCommandSource::Agent,
false,
);
let ctx = ToolEventCtx::new(session, turn, call_id, None);
let stage = if error.is_some() {
ToolEventStage::Failure(ToolEventFailure::Output(exec_output))
} else {
ToolEventStage::Success(exec_output)
};
emitter.emit(ctx, stage).await;
}
#[async_trait]
impl ToolHandler for PsReplHandler {
fn kind(&self) -> ToolKind {
ToolKind::Function
}
fn matches_kind(&self, payload: &ToolPayload) -> bool {
matches!(
payload,
ToolPayload::Function { .. } | ToolPayload::Custom { .. }
)
}
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
let ToolInvocation {
session,
turn,
tracker,
payload,
call_id,
..
} = invocation;
if !session.features().enabled(Feature::PsRepl) {
return Err(FunctionCallError::RespondToModel(
"ps_repl is disabled by feature flag".to_string(),
));
}
let args = match payload {
ToolPayload::Function { arguments } => parse_arguments(&arguments)?,
ToolPayload::Custom { input } => parse_freeform_args(&input)?,
_ => {
return Err(FunctionCallError::RespondToModel(
"ps_repl expects custom or function payload".to_string(),
));
}
};
let manager = turn.ps_repl.manager().await?;
let started_at = Instant::now();
emit_ps_repl_exec_begin(session.as_ref(), turn.as_ref(), &call_id).await;
let result = manager
.execute(Arc::clone(&session), Arc::clone(&turn), tracker, args)
.await;
let result = match result {
Ok(result) => result,
Err(err) => {
let message = err.to_string();
emit_ps_repl_exec_end(
session.as_ref(),
turn.as_ref(),
&call_id,
"",
Some(&message),
started_at.elapsed(),
)
.await;
return Err(err);
}
};
emit_ps_repl_exec_end(
session.as_ref(),
turn.as_ref(),
&call_id,
&result.output,
None,
started_at.elapsed(),
)
.await;
Ok(build_tool_output(result))
}
}
fn build_tool_output(result: PsExecResult) -> ToolOutput {
let PsExecResult {
output,
content_items,
} = result;
let mut items = Vec::with_capacity(content_items.len() + 1);
if !output.is_empty() {
items.push(FunctionCallOutputContentItem::InputText {
text: output.clone(),
});
}
items.extend(content_items);
ToolOutput::Function {
body: if items.is_empty() {
FunctionCallOutputBody::Text(output)
} else {
FunctionCallOutputBody::ContentItems(items)
},
success: Some(true),
}
}
#[async_trait]
impl ToolHandler for PsReplResetHandler {
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
if !invocation.session.features().enabled(Feature::PsRepl) {
return Err(FunctionCallError::RespondToModel(
"ps_repl is disabled by feature flag".to_string(),
));
}
let manager = invocation.turn.ps_repl.manager().await?;
manager.reset().await?;
Ok(ToolOutput::Function {
body: FunctionCallOutputBody::Text("ps_repl kernel reset".to_string()),
success: Some(true),
})
}
}
fn parse_freeform_args(input: &str) -> Result<PsReplArgs, FunctionCallError> {
if input.trim().is_empty() {
return Err(FunctionCallError::RespondToModel(
"ps_repl expects raw PowerShell tool input (non-empty). Provide PowerShell source text, optionally with first-line `# codex-ps-repl: ...`."
.to_string(),
));
}
let mut args = PsReplArgs {
code: input.to_string(),
timeout_ms: None,
};
let mut lines = input.splitn(2, '\n');
let first_line = lines.next().unwrap_or_default();
let rest = lines.next().unwrap_or_default();
let trimmed = first_line.trim_start();
let Some(pragma) = trimmed.strip_prefix(PS_REPL_PRAGMA_PREFIX) else {
reject_json_or_quoted_source(&args.code)?;
return Ok(args);
};
let mut timeout_ms: Option<u64> = None;
let directive = pragma.trim();
if !directive.is_empty() {
for token in directive.split_whitespace() {
let (key, value) = token.split_once('=').ok_or_else(|| {
FunctionCallError::RespondToModel(format!(
"ps_repl pragma expects space-separated key=value pairs (supported keys: timeout_ms); got `{token}`"
))
})?;
match key {
"timeout_ms" => {
if timeout_ms.is_some() {
return Err(FunctionCallError::RespondToModel(
"ps_repl pragma specifies timeout_ms more than once".to_string(),
));
}
let parsed = value.parse::<u64>().map_err(|_| {
FunctionCallError::RespondToModel(format!(
"ps_repl pragma timeout_ms must be an integer; got `{value}`"
))
})?;
timeout_ms = Some(parsed);
}
_ => {
return Err(FunctionCallError::RespondToModel(format!(
"ps_repl pragma only supports timeout_ms; got `{key}`"
)));
}
}
}
}
if rest.trim().is_empty() {
return Err(FunctionCallError::RespondToModel(
"ps_repl pragma must be followed by PowerShell source on subsequent lines".to_string(),
));
}
reject_json_or_quoted_source(rest)?;
args.code = rest.to_string();
args.timeout_ms = timeout_ms;
Ok(args)
}
fn reject_json_or_quoted_source(code: &str) -> Result<(), FunctionCallError> {
let trimmed = code.trim();
if trimmed.starts_with("```") {
return Err(FunctionCallError::RespondToModel(
"ps_repl expects raw PowerShell source, not markdown code fences. Resend plain PowerShell only (optional first line `# codex-ps-repl: ...`)."
.to_string(),
));
}
if is_quoted_source(trimmed) {
return Err(FunctionCallError::RespondToModel(
"ps_repl is a freeform tool and expects raw PowerShell source. Resend plain PowerShell only (optional first line `# codex-ps-repl: ...`); do not send quoted code or markdown fences."
.to_string(),
));
}
let Ok(value) = serde_json::from_str::<JsonValue>(trimmed) else {
return Ok(());
};
match value {
JsonValue::Object(_) | JsonValue::String(_) => Err(FunctionCallError::RespondToModel(
"ps_repl is a freeform tool and expects raw PowerShell source. Resend plain PowerShell only (optional first line `# codex-ps-repl: ...`); do not send JSON (`{\"code\":...}`), quoted code, or markdown fences."
.to_string(),
)),
_ => Ok(()),
}
}
fn is_quoted_source(input: &str) -> bool {
input.len() >= 2
&& ((input.starts_with('"') && input.ends_with('"'))
|| (input.starts_with('\'') && input.ends_with('\'')))
}
#[cfg(test)]
mod tests {
use super::parse_freeform_args;
use pretty_assertions::assert_eq;
#[test]
fn parse_freeform_args_without_pragma() {
let args = parse_freeform_args("Write-Output 'ok'").expect("parse args");
assert_eq!(args.code, "Write-Output 'ok'");
assert_eq!(args.timeout_ms, None);
}
#[test]
fn parse_freeform_args_with_pragma() {
let input = "# codex-ps-repl: timeout_ms=15000\nWrite-Output 'ok'";
let args = parse_freeform_args(input).expect("parse args");
assert_eq!(args.code, "Write-Output 'ok'");
assert_eq!(args.timeout_ms, Some(15_000));
}
#[test]
fn parse_freeform_args_rejects_unknown_key() {
let err = parse_freeform_args("# codex-ps-repl: nope=1\nWrite-Output 'ok'")
.expect_err("expected error");
assert_eq!(
err.to_string(),
"ps_repl pragma only supports timeout_ms; got `nope`"
);
}
#[test]
fn parse_freeform_args_rejects_json_wrapped_code() {
let err =
parse_freeform_args(r#"{"code":"Write-Output 'ok'"}"#).expect_err("expected error");
assert_eq!(
err.to_string(),
"ps_repl is a freeform tool and expects raw PowerShell source. Resend plain PowerShell only (optional first line `# codex-ps-repl: ...`); do not send JSON (`{\"code\":...}`), quoted code, or markdown fences."
);
}
#[test]
fn parse_freeform_args_rejects_quoted_source() {
let err = parse_freeform_args("'Write-Output ok'").expect_err("expected error");
assert_eq!(
err.to_string(),
"ps_repl is a freeform tool and expects raw PowerShell source. Resend plain PowerShell only (optional first line `# codex-ps-repl: ...`); do not send quoted code or markdown fences."
);
}
}

View File

@@ -5,6 +5,7 @@ pub mod js_repl;
pub(crate) mod network_approval;
pub mod orchestrator;
pub mod parallel;
pub mod ps_repl;
pub mod registry;
pub mod router;
pub mod runtimes;

View File

@@ -0,0 +1,187 @@
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
[Console]::InputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$script:CodexTmpDir = if ($env:CODEX_PS_TMP_DIR) {
$env:CODEX_PS_TMP_DIR
} else {
(Get-Location).Path
}
$script:ToolCounter = 0
$script:ActiveExecId = $null
function Send-KernelMessage {
param(
[Parameter(Mandatory)]
[hashtable]$Message
)
$json = $Message | ConvertTo-Json -Compress -Depth 100
[Console]::Out.WriteLine($json)
[Console]::Out.Flush()
}
function Read-KernelMessage {
while ($true) {
$line = [Console]::In.ReadLine()
if ($null -eq $line) {
return $null
}
if ([string]::IsNullOrWhiteSpace($line)) {
continue
}
try {
return $line | ConvertFrom-Json -AsHashtable -Depth 100
} catch {
continue
}
}
}
function Format-StreamItem {
param($Item)
if ($null -eq $Item) {
return $null
}
if ($Item -is [string]) {
return $Item.TrimEnd("`r", "`n")
}
$text = $Item | Out-String -Width 4096
$trimmed = $text.TrimEnd("`r", "`n")
if ([string]::IsNullOrEmpty($trimmed)) {
return $null
}
$trimmed
}
function Wait-ToolResult {
param(
[Parameter(Mandatory)]
[string]$Id
)
while ($true) {
$message = Read-KernelMessage
if ($null -eq $message) {
throw "ps_repl kernel closed while waiting for tool result"
}
if ($message.type -ne 'run_tool_result') {
throw "ps_repl kernel received unexpected message while waiting for tool result: $($message.type)"
}
if ($message.id -ne $Id) {
throw "ps_repl kernel received mismatched tool result: expected $Id, got $($message.id)"
}
return $message
}
}
function Invoke-CodexTool {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Name,
[object]$Arguments
)
if ([string]::IsNullOrWhiteSpace($Name)) {
throw "Invoke-CodexTool expects a non-empty tool name"
}
if ($null -eq $script:ActiveExecId) {
throw "Invoke-CodexTool can only be used while a ps_repl exec is running"
}
$toolId = "{0}-tool-{1}" -f $script:ActiveExecId, $script:ToolCounter
$script:ToolCounter += 1
$argumentsJson = '{}'
if ($PSBoundParameters.ContainsKey('Arguments')) {
if ($Arguments -is [string]) {
$argumentsJson = $Arguments
} else {
$argumentsJson = $Arguments | ConvertTo-Json -Compress -Depth 100
}
}
Send-KernelMessage @{
type = 'run_tool'
id = $toolId
exec_id = $script:ActiveExecId
tool_name = $Name
arguments = $argumentsJson
}
$result = Wait-ToolResult -Id $toolId
if (-not $result.ok) {
if ($null -ne $result.error -and -not [string]::IsNullOrWhiteSpace([string]$result.error)) {
throw [System.Exception]::new([string]$result.error)
}
throw [System.Exception]::new('tool failed')
}
$result.response
}
$script:Codex = [pscustomobject]@{
TmpDir = $script:CodexTmpDir
}
$null = $script:Codex | Add-Member -MemberType ScriptMethod -Name Tool -Value {
param($Name, $Arguments)
if ($PSBoundParameters.ContainsKey('Arguments')) {
Invoke-CodexTool -Name $Name -Arguments $Arguments
} else {
Invoke-CodexTool -Name $Name
}
}
Set-Variable -Name Codex -Scope Script -Value $script:Codex
Set-Variable -Name CodexTmpDir -Scope Script -Value $script:CodexTmpDir
while ($true) {
$message = Read-KernelMessage
if ($null -eq $message) {
break
}
if ($message.type -ne 'exec') {
continue
}
$script:ActiveExecId = [string]$message.id
try {
$scriptBlock = [scriptblock]::Create([string]$message.code)
$items = @(. $scriptBlock *>&1)
$outputLines = foreach ($item in $items) {
$formatted = Format-StreamItem -Item $item
if ($null -ne $formatted -and $formatted -ne '') {
$formatted
}
}
$output = [string]::Join("`n", @($outputLines))
Send-KernelMessage @{
type = 'exec_result'
id = [string]$message.id
ok = $true
output = $output
error = $null
}
} catch {
$errorMessage = if ($_.Exception -and $_.Exception.Message) {
[string]$_.Exception.Message
} else {
[string]$_
}
Send-KernelMessage @{
type = 'exec_result'
id = [string]$message.id
ok = $false
output = ''
error = $errorMessage
}
} finally {
$script:ActiveExecId = $null
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -52,6 +52,7 @@ pub(crate) struct ToolsConfig {
pub agent_roles: BTreeMap<String, AgentRoleConfig>,
pub search_tool: bool,
pub request_permission_enabled: bool,
pub ps_repl_enabled: bool,
pub js_repl_enabled: bool,
pub js_repl_tools_only: bool,
pub collab_tools: bool,
@@ -77,6 +78,7 @@ impl ToolsConfig {
session_source,
} = params;
let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform);
let include_ps_repl = features.enabled(Feature::PsRepl);
let include_js_repl = features.enabled(Feature::JsRepl);
let include_js_repl_tools_only =
include_js_repl && features.enabled(Feature::JsReplToolsOnly);
@@ -136,6 +138,7 @@ impl ToolsConfig {
agent_roles: BTreeMap::new(),
search_tool: include_search_tool,
request_permission_enabled,
ps_repl_enabled: include_ps_repl,
js_repl_enabled: include_js_repl,
js_repl_tools_only: include_js_repl_tools_only,
collab_tools: include_collab_tools,
@@ -1329,6 +1332,47 @@ JS_SOURCE: /(?:\s*)(?:[^\s{\"`]|`[^`]|``[^`])[\s\S]*/
})
}
fn create_ps_repl_tool() -> ToolSpec {
const PS_REPL_FREEFORM_GRAMMAR: &str = r#"
start: pragma_source | plain_source
pragma_source: PRAGMA_LINE NEWLINE ps_source
plain_source: PS_SOURCE
ps_source: PS_SOURCE
PRAGMA_LINE: /[ \t]*# codex-ps-repl:[^\r\n]*/
NEWLINE: /\r?\n/
PS_SOURCE: /(?:\s*)(?:[^\s{\"'`]|#[^\r\n]|`[^`])[\s\S]*/
"#;
ToolSpec::Freeform(FreeformTool {
name: "ps_repl".to_string(),
description: "Runs PowerShell in a persistent pwsh kernel. This is a freeform tool: send raw PowerShell source text, optionally with a first-line pragma like `# codex-ps-repl: timeout_ms=15000`; do not send JSON/quotes/markdown fences."
.to_string(),
format: FreeformToolFormat {
r#type: "grammar".to_string(),
syntax: "lark".to_string(),
definition: PS_REPL_FREEFORM_GRAMMAR.to_string(),
},
})
}
fn create_ps_repl_reset_tool() -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "ps_repl_reset".to_string(),
description:
"Restarts the ps_repl kernel for this run and clears persisted PowerShell session state."
.to_string(),
strict: false,
parameters: JsonSchema::Object {
properties: BTreeMap::new(),
required: None,
additional_properties: Some(false.into()),
},
})
}
fn create_js_repl_reset_tool() -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "js_repl_reset".to_string(),
@@ -1658,6 +1702,8 @@ pub(crate) fn build_specs(
use crate::tools::handlers::McpResourceHandler;
use crate::tools::handlers::MultiAgentHandler;
use crate::tools::handlers::PlanHandler;
use crate::tools::handlers::PsReplHandler;
use crate::tools::handlers::PsReplResetHandler;
use crate::tools::handlers::ReadFileHandler;
use crate::tools::handlers::RequestUserInputHandler;
use crate::tools::handlers::SearchToolBm25Handler;
@@ -1683,6 +1729,8 @@ pub(crate) fn build_specs(
default_mode_request_user_input: config.default_mode_request_user_input,
});
let search_tool_handler = Arc::new(SearchToolBm25Handler);
let ps_repl_handler = Arc::new(PsReplHandler);
let ps_repl_reset_handler = Arc::new(PsReplResetHandler);
let js_repl_handler = Arc::new(JsReplHandler);
let js_repl_reset_handler = Arc::new(JsReplResetHandler);
let request_permission_enabled = config.request_permission_enabled;
@@ -1737,6 +1785,13 @@ pub(crate) fn build_specs(
builder.push_spec(PLAN_TOOL.clone());
builder.register_handler("update_plan", plan_handler);
if config.ps_repl_enabled {
builder.push_spec(create_ps_repl_tool());
builder.push_spec(create_ps_repl_reset_tool());
builder.register_handler("ps_repl", ps_repl_handler);
builder.register_handler("ps_repl_reset", ps_repl_reset_handler);
}
if config.js_repl_enabled {
builder.push_spec(create_js_repl_tool());
builder.push_spec(create_js_repl_reset_tool());
@@ -2234,6 +2289,62 @@ mod tests {
);
}
#[test]
fn ps_repl_requires_feature_flag() {
let config = test_config();
let model_info =
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
let features = Features::with_defaults();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
assert!(
!tools.iter().any(|tool| tool.spec.name() == "ps_repl"),
"ps_repl should be disabled when the feature is off"
);
assert!(
!tools.iter().any(|tool| tool.spec.name() == "ps_repl_reset"),
"ps_repl_reset should be disabled when the feature is off"
);
}
#[test]
fn ps_repl_enabled_adds_tools() {
let config = test_config();
let model_info =
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.enable(Feature::PsRepl);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
session_source: SessionSource::Cli,
});
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
assert_contains_tool_names(&tools, &["ps_repl", "ps_repl_reset"]);
}
#[test]
fn ps_repl_freeform_grammar_mentions_pragma_and_ps_source() {
let ToolSpec::Freeform(FreeformTool { format, .. }) = create_ps_repl_tool() else {
panic!("ps_repl should use a freeform tool spec");
};
assert_eq!(format.syntax, "lark");
assert!(format.definition.contains("PRAGMA_LINE"));
assert!(format.definition.contains("PS_SOURCE"));
assert!(format.definition.contains("codex-ps-repl:"));
assert!(!format.definition.contains("(?!"));
}
#[test]
fn js_repl_enabled_adds_tools() {
let config = test_config();

View File

@@ -97,6 +97,7 @@ mod personality;
mod personality_migration;
mod plugins;
mod prompt_caching;
mod ps_repl;
mod quota_exceeded;
mod read_file;
mod realtime_conversation;

View File

@@ -0,0 +1,484 @@
#![allow(clippy::expect_used, clippy::unwrap_used)]
use anyhow::Result;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_core::features::Feature;
use codex_protocol::protocol::EventMsg;
use core_test_support::responses;
use core_test_support::responses::ResponseMock;
use core_test_support::responses::ResponsesRequest;
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_response_created;
use core_test_support::responses::sse;
use core_test_support::skip_if_no_network;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event_match;
use serde_json::Value;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use tempfile::tempdir;
use wiremock::MockServer;
fn custom_tool_output_text_and_success(
req: &ResponsesRequest,
call_id: &str,
) -> (String, Option<bool>) {
let (output, success) = req
.custom_tool_call_output_content_and_success(call_id)
.expect("custom tool output should be present");
(output.unwrap_or_default(), success)
}
fn tool_names(body: &serde_json::Value) -> Vec<String> {
body["tools"]
.as_array()
.expect("tools array should be present")
.iter()
.map(|tool| {
tool.get("name")
.and_then(|value| value.as_str())
.or_else(|| tool.get("type").and_then(|value| value.as_str()))
.expect("tool should have a name or type")
.to_string()
})
.collect()
}
fn write_too_old_pwsh_script(dir: &Path) -> Result<std::path::PathBuf> {
#[cfg(windows)]
{
let path = dir.join("old-pwsh.cmd");
fs::write(&path, "@echo off\r\necho PowerShell 5.1.0\r\n")?;
Ok(path)
}
#[cfg(unix)]
{
let path = dir.join("old-pwsh.sh");
fs::write(&path, "#!/bin/sh\necho PowerShell 5.1.0\n")?;
let mut permissions = fs::metadata(&path)?.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&path, permissions)?;
Ok(path)
}
#[cfg(not(any(unix, windows)))]
{
anyhow::bail!("unsupported platform for ps_repl test fixture");
}
}
fn write_test_png(dir: &Path) -> Result<std::path::PathBuf> {
let path = dir.join("dot.png");
let png_bytes = BASE64_STANDARD.decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
)?;
fs::write(&path, png_bytes)?;
Ok(path)
}
fn ps_single_quote(input: &Path) -> String {
input.display().to_string().replace('\'', "''")
}
async fn run_ps_repl_turn(
server: &MockServer,
prompt: &str,
calls: &[(&str, &str)],
) -> Result<ResponseMock> {
let test = test_codex()
.with_config(|config| {
config.features.enable(Feature::PsRepl);
})
.build(server)
.await?;
let mut first_events = vec![ev_response_created("resp-1")];
for (call_id, ps_input) in calls {
first_events.push(ev_custom_tool_call(call_id, "ps_repl", ps_input));
}
first_events.push(ev_completed("resp-1"));
responses::mount_sse_once(server, sse(first_events)).await;
let second_mock = responses::mount_sse_once(
server,
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-2"),
]),
)
.await;
test.submit_turn(prompt).await?;
Ok(second_mock)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ps_repl_is_not_advertised_when_startup_pwsh_is_incompatible() -> Result<()> {
skip_if_no_network!(Ok(()));
if std::env::var_os("CODEX_PS_REPL_PATH").is_some() {
return Ok(());
}
let server = responses::start_mock_server().await;
let temp = tempdir()?;
let old_pwsh = write_too_old_pwsh_script(temp.path())?;
let test = test_codex()
.with_config(move |config| {
config.features.enable(Feature::PsRepl);
config.ps_repl_path = Some(old_pwsh);
})
.build(&server)
.await?;
let warning = wait_for_event_match(&test.codex, |event| match event {
EventMsg::Warning(ev) if ev.message.contains("Disabled `ps_repl` for this session") => {
Some(ev.message.clone())
}
_ => None,
})
.await;
assert!(
warning.contains("PowerShell runtime"),
"warning should explain the PowerShell compatibility issue: {warning}"
);
let request_mock = responses::mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-1"),
]),
)
.await;
test.submit_turn("hello").await?;
let body = request_mock.single_request().body_json();
let tools = tool_names(&body);
assert!(
!tools.iter().any(|tool| tool == "ps_repl"),
"ps_repl should be omitted when startup validation fails: {tools:?}"
);
assert!(
!tools.iter().any(|tool| tool == "ps_repl_reset"),
"ps_repl_reset should be omitted when startup validation fails: {tools:?}"
);
let instructions = body["instructions"].as_str().unwrap_or_default();
assert!(
!instructions.contains("## PowerShell REPL (pwsh)"),
"startup instructions should not mention ps_repl when it is disabled: {instructions}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ps_repl_persists_variables_functions_and_modules() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let test = test_codex()
.with_config(|config| {
config.features.enable(Feature::PsRepl);
})
.build(&server)
.await?;
responses::mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_custom_tool_call(
"call-1",
"ps_repl",
r#"
$x = 41
New-Module -Name CodexPsTestModule -ScriptBlock {
function Get-CodexValue { 42 }
} | Import-Module
function Add-One {
param([int]$Value)
$Value + 1
}
Write-Output "state-ready"
"#,
),
ev_completed("resp-1"),
]),
)
.await;
let second_mock = responses::mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-2"),
ev_custom_tool_call(
"call-2",
"ps_repl",
r#"
Write-Output ($x + 1)
Write-Output (Add-One -Value 1)
Write-Output (Get-CodexValue)
"#,
),
ev_completed("resp-2"),
]),
)
.await;
let third_mock = responses::mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-3"),
]),
)
.await;
test.submit_turn("run ps_repl twice").await?;
let req2 = second_mock.single_request();
let (first_output, first_success) = custom_tool_output_text_and_success(&req2, "call-1");
assert_ne!(
first_success,
Some(false),
"first ps_repl call failed unexpectedly: {first_output}"
);
assert!(first_output.contains("state-ready"));
let req3 = third_mock.single_request();
let (second_output, second_success) = custom_tool_output_text_and_success(&req3, "call-2");
assert_ne!(
second_success,
Some(false),
"second ps_repl call failed unexpectedly: {second_output}"
);
let lines = second_output
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>();
assert!(
lines.iter().filter(|line| **line == "42").count() >= 2,
"expected persisted variable and module output, got: {second_output}"
);
assert!(
lines.contains(&"2"),
"expected persisted function output, got: {second_output}"
);
assert!(
!second_output.contains("Get-CodexValue"),
"unexpected formatting leak: {second_output}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ps_repl_can_invoke_builtin_tools() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mock = run_ps_repl_turn(
&server,
"use ps_repl to call a tool",
&[(
"call-1",
"$toolOut = Invoke-CodexTool -Name list_mcp_resources -Arguments @{}; Write-Output $toolOut.type",
)],
)
.await?;
let req = mock.single_request();
let (output, success) = custom_tool_output_text_and_success(&req, "call-1");
assert_ne!(
success,
Some(false),
"ps_repl call failed unexpectedly: {output}"
);
assert!(output.contains("function_call_output"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ps_repl_tool_call_rejects_recursive_ps_repl_invocation() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mock = run_ps_repl_turn(
&server,
"use ps_repl recursively",
&[(
"call-1",
r#"
try {
Invoke-CodexTool -Name ps_repl -Arguments "Write-Output 'recursive'" | Out-Null
Write-Output "unexpected-success"
} catch {
Write-Output $_.Exception.Message
}
"#,
)],
)
.await?;
let req = mock.single_request();
let (output, success) = custom_tool_output_text_and_success(&req, "call-1");
assert_ne!(
success,
Some(false),
"ps_repl call failed unexpectedly: {output}"
);
assert!(
output.contains("ps_repl cannot invoke itself"),
"expected recursion guard message, got output: {output}"
);
assert!(
!output.contains("unexpected-success"),
"recursive ps_repl call unexpectedly succeeded: {output}"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ps_repl_resets_after_timeout_and_accepts_followup_execution() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let test = test_codex()
.with_config(|config| {
config.features.enable(Feature::PsRepl);
})
.build(&server)
.await?;
responses::mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-1"),
ev_custom_tool_call(
"call-1",
"ps_repl",
"# codex-ps-repl: timeout_ms=50\nStart-Sleep -Milliseconds 500",
),
ev_completed("resp-1"),
]),
)
.await;
let second_mock = responses::mount_sse_once(
&server,
sse(vec![
ev_response_created("resp-2"),
ev_custom_tool_call("call-2", "ps_repl", "Write-Output 'healthy'"),
ev_completed("resp-2"),
]),
)
.await;
let third_mock = responses::mount_sse_once(
&server,
sse(vec![
ev_assistant_message("msg-1", "done"),
ev_completed("resp-3"),
]),
)
.await;
test.submit_turn("run ps_repl after timeout").await?;
let req2 = second_mock.single_request();
let (first_output, first_success) = custom_tool_output_text_and_success(&req2, "call-1");
assert_ne!(
first_success,
Some(true),
"timeout should not report success: {first_output}"
);
assert!(
first_output.contains("ps_repl execution timed out"),
"expected timeout output, got: {first_output}"
);
let req3 = third_mock.single_request();
let (second_output, second_success) = custom_tool_output_text_and_success(&req3, "call-2");
assert_ne!(
second_success,
Some(false),
"ps_repl follow-up execution failed unexpectedly: {second_output}"
);
assert!(second_output.contains("healthy"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ps_repl_captures_standard_powershell_output_streams() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mock = run_ps_repl_turn(
&server,
"capture powershell output",
&[(
"call-1",
"Write-Output 'stdout'; Write-Warning 'warn-stream'",
)],
)
.await?;
let req = mock.single_request();
let (output, success) = custom_tool_output_text_and_success(&req, "call-1");
assert_ne!(
success,
Some(false),
"ps_repl call failed unexpectedly: {output}"
);
assert!(output.contains("stdout"));
assert!(output.contains("warn-stream"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ps_repl_view_image_propagates_content_items() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let temp = tempdir()?;
let png_path = write_test_png(temp.path())?;
let png_path = ps_single_quote(&png_path);
let script =
format!("$null = Invoke-CodexTool -Name view_image -Arguments @{{ path = '{png_path}' }}");
let mock = run_ps_repl_turn(
&server,
"render an image via ps_repl",
&[("call-1", &script)],
)
.await?;
let req = mock.single_request();
let custom_output = req.custom_tool_call_output("call-1");
let output_items = custom_output
.get("output")
.and_then(Value::as_array)
.expect("custom_tool_call_output should be a content item array");
let image_url = output_items
.iter()
.find_map(|item| {
(item.get("type").and_then(Value::as_str) == Some("input_image"))
.then(|| item.get("image_url").and_then(Value::as_str))
.flatten()
})
.expect("image_url present in ps_repl custom tool output");
assert!(
image_url.starts_with("data:image/png;base64,"),
"expected png data URL, got {image_url}"
);
Ok(())
}

View File

@@ -277,6 +277,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
model_provider: model_provider.clone(),
codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(),
main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(),
ps_repl_path: None,
js_repl_node_path: None,
js_repl_node_module_dirs: None,
zsh_path: None,

68
docs/ps_repl.md Normal file
View File

@@ -0,0 +1,68 @@
# PowerShell REPL (`ps_repl`)
`ps_repl` runs PowerShell in a persistent `pwsh`-backed kernel.
## Feature gate
`ps_repl` is disabled by default and only appears when:
```toml
[features]
ps_repl = true
```
The initial rollout stage is under development.
## PowerShell runtime
`ps_repl` requires PowerShell 7 or newer.
Runtime resolution order:
1. `CODEX_PS_REPL_PATH` environment variable
2. `ps_repl_path` in config/profile
3. `pwsh` discovered on `PATH`
You can configure an explicit runtime path:
```toml
ps_repl_path = "/absolute/path/to/pwsh"
```
If only Windows PowerShell (`powershell.exe`) is available, `ps_repl` stays disabled and emits a startup warning telling you to install PowerShell 7.
## Usage
- `ps_repl` is a freeform tool: send raw PowerShell source text.
- Optional first-line pragma:
- `# codex-ps-repl: timeout_ms=15000`
- Variables, functions, aliases, imported modules, environment changes, and `$LASTEXITCODE` persist across calls.
- Use `ps_repl_reset` to clear the kernel state.
## Helper APIs inside the kernel
`ps_repl` exposes these helpers:
- `$CodexTmpDir`: per-session scratch directory path.
- `Invoke-CodexTool -Name <string> -Arguments <object|string>`: executes a normal Codex tool call from inside `ps_repl`.
- `$Codex.TmpDir`: alias for the scratch directory.
- `$Codex.Tool(<name>, <args>)`: thin alias to `Invoke-CodexTool`.
`Invoke-CodexTool` returns the raw tool output object. Nested tool calls can also return multimodal content such as `view_image` results.
To share generated images with the model, write a file under `$CodexTmpDir`, call:
```powershell
Invoke-CodexTool -Name view_image -Arguments @{ path = "/absolute/path" }
```
Then delete the file.
## Output and transport
`ps_repl` uses a JSON-line transport over stdio.
- Safe output forms: pipeline output, `Write-Output`, `Write-Host`, `Write-Verbose`, `Write-Warning`
- Avoid: direct `[Console]::Write*`, raw StdOut/StdErr writes, or other host-level output that bypasses PowerShell streams
Bypassing PowerShell streams can corrupt the transport between the Rust host and the persistent kernel.