Compare commits

...

2 Commits

Author SHA1 Message Date
Liang-Ting Jiang
e6a3d48611 Gate Library uploads on non-read-only Apps tools 2026-05-29 02:38:49 -07:00
iceweasel-oai
d9f53128b7 [codex] Handle PowerShell UTF-8 setup failures (#24949)
Fixes #12496.

## Why

Windows sandboxed PowerShell commands can run under
`ConstrainedLanguage` on some machines, especially enterprise-managed
Windows environments. In that mode, our PowerShell command prelude could
fail before every command because it directly assigned
`[Console]::OutputEncoding` to UTF-8. The actual user command still ran,
but Codex surfaced noisy `Cannot set property. Property setting is
supported only on core types in this language mode.` output for every
shell call.

## What Changed

- Makes the PowerShell UTF-8 output encoding prelude best-effort by
wrapping the assignment in `try { ... } catch {}`.
- Keeps the existing UTF-8 behavior when PowerShell allows the
assignment.
- Adds focused tests for adding the prelude and avoiding duplicate
prelude insertion.

## Validation

- `cargo fmt -p codex-shell-command`
- `cargo check -p codex-shell-command`
- `git diff --check`
- Verified a local `ConstrainedLanguage` PowerShell probe prints only
the command output with no property-setting error.
- Verified `codex exec` from a temporary `chcp 437` context reports
`utf-8` / `65001` and preserves non-ASCII output (`café`, `漢字`).
2026-05-28 13:58:20 -07:00
6 changed files with 298 additions and 28 deletions

View File

@@ -98,6 +98,7 @@ pub async fn upload_local_file(
base_url: &str,
auth: &dyn AuthProvider,
path: &Path,
store_in_oai_library: bool,
) -> Result<UploadedOpenAiFile, OpenAiFileError> {
let metadata = tokio::fs::metadata(path)
.await
@@ -129,12 +130,17 @@ pub async fn upload_local_file(
.unwrap_or("file")
.to_string();
let create_url = format!("{}/files", base_url.trim_end_matches('/'));
let mut create_body = serde_json::json!({
"file_name": file_name,
"file_size": metadata.len(),
"use_case": OPENAI_FILE_USE_CASE,
});
if store_in_oai_library {
create_body["store_in_library"] = serde_json::Value::Bool(true);
}
let create_response = authorized_request(auth, reqwest::Method::POST, &create_url)
.json(&serde_json::json!({
"file_name": file_name,
"file_size": metadata.len(),
"use_case": OPENAI_FILE_USE_CASE,
}))
.json(&create_body)
.send()
.await
.map_err(|source| OpenAiFileError::Request {
@@ -363,9 +369,14 @@ mod tests {
let path = dir.path().join("hello.txt");
tokio::fs::write(&path, b"hello").await.expect("write file");
let uploaded = upload_local_file(&base_url, &chatgpt_auth(), &path)
.await
.expect("upload succeeds");
let uploaded = upload_local_file(
&base_url,
&chatgpt_auth(),
&path,
/*store_in_oai_library*/ false,
)
.await
.expect("upload succeeds");
assert_eq!(uploaded.file_id, "file_123");
assert_eq!(uploaded.uri, "sediment://file_123");
@@ -377,4 +388,69 @@ mod tests {
assert_eq!(uploaded.mime_type, Some("text/plain".to_string()));
assert_eq!(finalize_attempts.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn upload_local_file_can_request_oai_library_storage() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/backend-api/files"))
.and(header("chatgpt-account-id", "account_id"))
.and(body_json(serde_json::json!({
"file_name": "library-note.txt",
"file_size": 7,
"use_case": "codex",
"store_in_library": true,
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"file_id": "file_library",
"upload_url": format!("{}/upload/file_library", server.uri()),
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path("/upload/file_library"))
.and(header("content-length", "7"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/backend-api/files/file_library/uploaded"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"status": "success",
"download_url": format!("{}/download/file_library", server.uri()),
"file_name": "library-note.txt",
"mime_type": "text/plain",
"file_size_bytes": 7,
})))
.expect(1)
.mount(&server)
.await;
let base_url = base_url_for(&server);
let dir = TempDir::new().expect("temp dir");
let path = dir.path().join("library-note.txt");
tokio::fs::write(&path, b"library")
.await
.expect("write file");
let uploaded = upload_local_file(
&base_url,
&chatgpt_auth(),
&path,
/*store_in_oai_library*/ true,
)
.await
.expect("upload succeeds");
assert_eq!(uploaded.file_id, "file_library");
assert_eq!(uploaded.uri, "sediment://file_library");
assert_eq!(
uploaded.download_url,
format!("{}/download/file_library", server.uri())
);
server.verify().await;
}
}

View File

@@ -303,7 +303,7 @@ struct CodexAppsServerInfoDiskCache {
}
const CODEX_APPS_TOOLS_CACHE_DIR: &str = "cache/codex_apps_tools";
pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 3;
pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 4;
const CODEX_APPS_SERVER_INFO_CACHE_DIR: &str = "cache/codex_apps_server_info";
const CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION: u8 = 1;

View File

@@ -3,6 +3,9 @@
//! Strategy:
//! - Inspect `_meta["openai/fileParams"]` to discover which tool arguments are
//! file inputs.
//! - Inspect validated `_meta["openai/fileUploadConfig"]` metadata only for
//! Apps tools that visibly declare a write action before opting uploaded
//! local files into Library storage.
//! - At tool execution time, upload those local files to OpenAI file storage
//! and rewrite only the declared arguments into the provided-file payload
//! shape expected by the downstream Apps tool.
@@ -16,11 +19,17 @@ use codex_api::upload_local_file;
use codex_login::CodexAuth;
use serde_json::Value as JsonValue;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct OpenAIFileInputParams {
pub(crate) names: Vec<String>,
pub(crate) store_in_oai_library: bool,
}
pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files(
sess: &Session,
turn_context: &TurnContext,
arguments_value: Option<JsonValue>,
openai_file_input_params: Option<&[String]>,
openai_file_input_params: Option<&OpenAIFileInputParams>,
) -> Result<Option<JsonValue>, String> {
let Some(openai_file_input_params) = openai_file_input_params else {
return Ok(arguments_value);
@@ -34,14 +43,21 @@ pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files(
};
let auth = sess.services.auth_manager.auth().await;
let mut rewritten_arguments = arguments.clone();
let store_in_oai_library =
store_in_oai_library_for_openai_file_upload(openai_file_input_params);
for field_name in openai_file_input_params {
for field_name in &openai_file_input_params.names {
let Some(value) = arguments.get(field_name) else {
continue;
};
let Some(uploaded_value) =
rewrite_argument_value_for_openai_files(turn_context, auth.as_ref(), field_name, value)
.await?
let Some(uploaded_value) = rewrite_argument_value_for_openai_files(
turn_context,
auth.as_ref(),
field_name,
value,
store_in_oai_library,
)
.await?
else {
continue;
};
@@ -55,11 +71,18 @@ pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files(
Ok(Some(JsonValue::Object(rewritten_arguments)))
}
fn store_in_oai_library_for_openai_file_upload(
openai_file_input_params: &OpenAIFileInputParams,
) -> bool {
openai_file_input_params.store_in_oai_library
}
async fn rewrite_argument_value_for_openai_files(
turn_context: &TurnContext,
auth: Option<&CodexAuth>,
field_name: &str,
value: &JsonValue,
store_in_oai_library: bool,
) -> Result<Option<JsonValue>, String> {
match value {
JsonValue::String(path_or_file_ref) => {
@@ -69,6 +92,7 @@ async fn rewrite_argument_value_for_openai_files(
field_name,
/*index*/ None,
path_or_file_ref,
store_in_oai_library,
)
.await?;
Ok(Some(rewritten))
@@ -85,6 +109,7 @@ async fn rewrite_argument_value_for_openai_files(
field_name,
Some(index),
path_or_file_ref,
store_in_oai_library,
)
.await?;
rewritten_values.push(rewritten);
@@ -101,6 +126,7 @@ async fn build_uploaded_local_argument_value(
field_name: &str,
index: Option<usize>,
file_path: &str,
store_in_oai_library: bool,
) -> Result<JsonValue, String> {
#[allow(deprecated)]
let resolved_path = turn_context.resolve_path(Some(file_path.to_string()));
@@ -119,6 +145,7 @@ async fn build_uploaded_local_argument_value(
turn_context.config.chatgpt_base_url.trim_end_matches('/'),
upload_auth.as_ref(),
&resolved_path,
store_in_oai_library,
)
.await
.map_err(|error| match index {
@@ -165,8 +192,25 @@ mod tests {
assert_eq!(rewritten, arguments);
}
#[test]
fn store_in_oai_library_comes_from_approved_tool_metadata() {
let openai_file_input_params = OpenAIFileInputParams {
names: Vec::new(),
store_in_oai_library: true,
};
assert!(store_in_oai_library_for_openai_file_upload(
&openai_file_input_params
));
assert!(!store_in_oai_library_for_openai_file_upload(
&OpenAIFileInputParams {
names: Vec::new(),
store_in_oai_library: false,
},
));
}
#[tokio::test]
async fn build_uploaded_local_argument_value_uploads_local_file_path() {
async fn build_uploaded_local_argument_value_honors_upload_options() {
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
@@ -183,6 +227,7 @@ mod tests {
"file_name": "file_report.csv",
"file_size": 5,
"use_case": "codex",
"store_in_library": true,
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"file_id": "file_123",
@@ -232,6 +277,7 @@ mod tests {
"file",
/*index*/ None,
"file_report.csv",
/*store_in_oai_library*/ true,
)
.await
.expect("rewrite should upload the local file");
@@ -314,6 +360,7 @@ mod tests {
Some(&auth),
"file",
&serde_json::json!("file_report.csv"),
/*store_in_oai_library*/ false,
)
.await
.expect("rewrite should succeed");
@@ -431,6 +478,7 @@ mod tests {
Some(&auth),
"files",
&serde_json::json!(["one.csv", "two.csv"]),
/*store_in_oai_library*/ false,
)
.await
.expect("rewrite should succeed");
@@ -470,7 +518,10 @@ mod tests {
Some(serde_json::json!({
"file": "/definitely/missing/file.csv",
})),
Some(&["file".to_string()]),
Some(&OpenAIFileInputParams {
names: vec!["file".to_string()],
store_in_oai_library: false,
}),
)
.await
.expect_err("missing file should fail");

View File

@@ -15,6 +15,7 @@ use crate::guardian::new_guardian_review_id;
use crate::guardian::review_approval_request;
use crate::guardian::routes_approval_to_guardian;
use crate::hook_runtime::run_permission_request_hooks;
use crate::mcp_openai_file::OpenAIFileInputParams;
use crate::mcp_openai_file::rewrite_mcp_tool_arguments_for_openai_files;
use crate::mcp_tool_approval_templates::RenderedMcpToolApprovalParam;
use crate::mcp_tool_approval_templates::render_mcp_tool_approval_template;
@@ -328,7 +329,7 @@ async fn handle_approved_mcp_tool_call(
sess,
turn_context,
arguments_value.clone(),
metadata.and_then(|metadata| metadata.openai_file_input_params.as_deref()),
metadata.and_then(|metadata| metadata.openai_file_input_params.as_ref()),
)
.await;
let tool_input = match &rewrite {
@@ -976,13 +977,15 @@ pub(crate) struct McpToolApprovalMetadata {
tool_description: Option<String>,
mcp_app_resource_uri: Option<String>,
codex_apps_meta: Option<serde_json::Map<String, serde_json::Value>>,
openai_file_input_params: Option<Vec<String>>,
openai_file_input_params: Option<OpenAIFileInputParams>,
}
const MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY: &str = "openai/outputTemplate";
const MCP_TOOL_UI_RESOURCE_URI_META_KEY: &str = "ui/resourceUri";
const MCP_TOOL_PLUGIN_ID_META_KEY: &str = "plugin_id";
const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId";
const MCP_TOOL_OPENAI_FILE_UPLOAD_CONFIG_META_KEY: &str = "openai/fileUploadConfig";
const MCP_TOOL_OPENAI_FILE_UPLOAD_STORE_IN_OAI_LIBRARY_KEY: &str = "store_in_oai_library";
async fn custom_mcp_tool_approval_mode(
sess: &Session,
@@ -1444,8 +1447,10 @@ pub(crate) async fn lookup_mcp_tool_metadata(
None
};
let annotations = tool_info.tool.annotations.clone();
Some(McpToolApprovalMetadata {
annotations: tool_info.tool.annotations,
annotations: annotations.clone(),
connector_id: tool_info.connector_id,
connector_name: tool_info.connector_name,
connector_description,
@@ -1464,6 +1469,8 @@ pub(crate) async fn lookup_mcp_tool_metadata(
openai_file_input_params: openai_file_input_params_for_server(
server,
tool_info.tool.meta.as_deref(),
tool_info.tool.input_schema.as_ref(),
annotations.as_ref(),
),
})
}
@@ -1471,10 +1478,38 @@ pub(crate) async fn lookup_mcp_tool_metadata(
fn openai_file_input_params_for_server(
server: &str,
meta: Option<&serde_json::Map<String, serde_json::Value>>,
) -> Option<Vec<String>> {
(server == CODEX_APPS_MCP_SERVER_NAME)
.then_some(declared_openai_file_input_param_names(meta))
.filter(|params| !params.is_empty())
_input_schema: &serde_json::Map<String, serde_json::Value>,
annotations: Option<&ToolAnnotations>,
) -> Option<OpenAIFileInputParams> {
if server != CODEX_APPS_MCP_SERVER_NAME {
return None;
}
let names = declared_openai_file_input_param_names(meta);
if names.is_empty() {
return None;
}
Some(OpenAIFileInputParams {
names,
store_in_oai_library: declared_openai_file_upload_store_in_oai_library(meta, annotations),
})
}
fn declared_openai_file_upload_store_in_oai_library(
meta: Option<&serde_json::Map<String, serde_json::Value>>,
annotations: Option<&ToolAnnotations>,
) -> bool {
let tool_opted_in = meta
.and_then(|meta| meta.get(MCP_TOOL_OPENAI_FILE_UPLOAD_CONFIG_META_KEY))
.and_then(serde_json::Value::as_object)
.and_then(|config| config.get(MCP_TOOL_OPENAI_FILE_UPLOAD_STORE_IN_OAI_LIBRARY_KEY))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
// Hidden upload formatting may only add Library persistence after the
// explicit Apps action visibly declares itself non-read-only.
tool_opted_in && annotations.and_then(|annotations| annotations.read_only_hint) == Some(false)
}
fn get_mcp_app_resource_uri(

View File

@@ -344,17 +344,91 @@ fn openai_file_params_are_only_honored_for_codex_apps() {
"openai/fileParams": ["file"],
});
let meta = meta.as_object();
let input_schema = serde_json::json!({
"type": "object",
"properties": {},
});
let input_schema = input_schema.as_object().expect("input schema object");
assert_eq!(
openai_file_input_params_for_server(CODEX_APPS_MCP_SERVER_NAME, meta),
Some(vec!["file".to_string()])
openai_file_input_params_for_server(CODEX_APPS_MCP_SERVER_NAME, meta, input_schema, None,),
Some(OpenAIFileInputParams {
names: vec!["file".to_string()],
store_in_oai_library: false,
})
);
assert_eq!(
openai_file_input_params_for_server("minimaltest", meta),
openai_file_input_params_for_server("minimaltest", meta, input_schema, None),
None
);
}
#[test]
fn openai_file_upload_config_requires_visible_write_tool() {
let valid_meta_value = serde_json::json!({
"openai/fileParams": ["file"],
"openai/fileUploadConfig": {
"store_in_oai_library": true,
},
});
let valid_meta = valid_meta_value.as_object();
let valid_input_schema_value = serde_json::json!({
"type": "object",
"properties": {},
});
let valid_input_schema = valid_input_schema_value
.as_object()
.expect("input schema object");
let visible_write_annotations = annotations(Some(false), Some(false), Some(false));
assert_eq!(
openai_file_input_params_for_server(
CODEX_APPS_MCP_SERVER_NAME,
valid_meta,
valid_input_schema,
Some(&visible_write_annotations),
),
Some(OpenAIFileInputParams {
names: vec!["file".to_string()],
store_in_oai_library: true,
})
);
for (meta, input_schema, annotations) in [
(
serde_json::json!({
"openai/fileParams": ["file"],
"openai/fileUploadConfig": {
"store_in_oai_library_arg": "store_in_oai_library",
},
}),
valid_input_schema_value.clone(),
visible_write_annotations.clone(),
),
(
valid_meta_value.clone(),
serde_json::json!({
"type": "object",
"properties": {},
}),
annotations(Some(true), None, None),
),
] {
assert_eq!(
openai_file_input_params_for_server(
CODEX_APPS_MCP_SERVER_NAME,
meta.as_object(),
input_schema.as_object().expect("input schema object"),
Some(&annotations),
),
Some(OpenAIFileInputParams {
names: vec!["file".to_string()],
store_in_oai_library: false,
})
);
}
}
#[test]
fn approval_required_when_read_only_false_and_destructive() {
let annotations = annotations(Some(false), Some(true), /*open_world*/ None);

View File

@@ -8,8 +8,9 @@ use crate::shell_detect::detect_shell_type;
const POWERSHELL_FLAGS: &[&str] = &["-nologo", "-noprofile", "-command", "-c"];
/// Prefixed command for powershell shell calls to force UTF-8 console output.
pub const UTF8_OUTPUT_PREFIX: &str = "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;\n";
/// Prefixed command for powershell shell calls to request UTF-8 console output.
pub const UTF8_OUTPUT_PREFIX: &str =
"try { [Console]::OutputEncoding=[System.Text.Encoding]::UTF8 } catch {}\n";
pub fn prefix_powershell_script_with_utf8(command: &[String]) -> Vec<String> {
let Some((_, script)) = extract_powershell_command(command) else {
@@ -151,9 +152,11 @@ fn is_powershellish_executable_available(powershell_or_pwsh_exe: &std::path::Pat
#[cfg(test)]
mod tests {
use super::UTF8_OUTPUT_PREFIX;
use super::extract_powershell_command;
#[cfg(windows)]
use super::parse_powershell_command_into_plain_commands;
use super::prefix_powershell_script_with_utf8;
#[test]
fn extracts_basic_powershell_command() {
@@ -202,6 +205,37 @@ mod tests {
assert_eq!(script, "Get-ChildItem | Select-String foo");
}
#[test]
fn prefixes_powershell_command_with_best_effort_utf8() {
let cmd = vec![
"powershell".to_string(),
"-Command".to_string(),
"Write-Host hi".to_string(),
];
let prefixed = prefix_powershell_script_with_utf8(&cmd);
assert_eq!(
prefixed,
vec![
"powershell".to_string(),
"-Command".to_string(),
format!("{UTF8_OUTPUT_PREFIX}Write-Host hi"),
]
);
}
#[test]
fn does_not_duplicate_utf8_prefix() {
let cmd = vec![
"powershell".to_string(),
"-Command".to_string(),
format!("{UTF8_OUTPUT_PREFIX}Write-Host hi"),
];
assert_eq!(prefix_powershell_script_with_utf8(&cmd), cmd);
}
#[cfg(windows)]
#[test]
fn parses_plain_powershell_commands() {