This commit is contained in:
Matthew Zeng
2026-03-18 22:44:29 -07:00
parent 4d12fafef3
commit 352b6820dd
13 changed files with 451 additions and 236 deletions
+1 -1
View File
@@ -2116,8 +2116,8 @@ dependencies = [
name = "codex-i18n"
version = "0.0.0"
dependencies = [
"codex-utils-cargo-bin",
"fluent-bundle 0.16.0",
"include_dir",
"pretty_assertions",
"sys-locale",
"thiserror 2.0.18",
+2 -7
View File
@@ -195,10 +195,7 @@ pub(crate) async fn monitor_action(
} else if !rationale.is_empty() {
ArcMonitorOutcome::AskUser(rationale.to_string())
} else {
ArcMonitorOutcome::AskUser(
"Additional confirmation is required before this tool call can continue."
.to_string(),
)
ArcMonitorOutcome::AskUser(String::new())
}
}
ArcMonitorResultOutcome::SteerModel => {
@@ -207,9 +204,7 @@ pub(crate) async fn monitor_action(
} else if !short_reason.is_empty() {
ArcMonitorOutcome::SteerModel(short_reason.to_string())
} else {
ArcMonitorOutcome::SteerModel(
"Tool call was cancelled because of safety risks.".to_string(),
)
ArcMonitorOutcome::SteerModel(String::new())
}
}
}
+52
View File
@@ -329,6 +329,58 @@ async fn monitor_action_posts_expected_arc_request() {
);
}
#[tokio::test]
#[serial(arc_monitor_env)]
async fn monitor_action_returns_empty_reason_when_arc_requires_generic_confirmation() {
let server = MockServer::start().await;
let (session, mut turn_context) = make_session_and_context().await;
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
));
let mut config = (*turn_context.config).clone();
config.chatgpt_base_url = server.uri();
turn_context.config = Arc::new(config);
session
.record_into_history(
&[ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "please run the tool".to_string(),
}],
end_turn: None,
phase: None,
}],
&turn_context,
)
.await;
Mock::given(method("POST"))
.and(path("/codex/safety/arc"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"outcome": "ask-user",
"short_reason": "",
"rationale": "",
"risk_score": 42,
"risk_level": "medium",
"evidence": [],
})))
.expect(1)
.mount(&server)
.await;
let outcome = monitor_action(
&session,
&turn_context,
serde_json::json!({ "tool": "mcp_tool_call" }),
)
.await;
assert_eq!(outcome, ArcMonitorOutcome::AskUser(String::new()));
}
#[tokio::test]
#[serial(arc_monitor_env)]
async fn monitor_action_uses_env_url_and_token_overrides() {
@@ -135,7 +135,8 @@ fn render_question_template(
let connector_name = connector_name
.map(str::trim)
.filter(|name| !name.is_empty())?;
let rendered = format_message(locale, question_id, &[("connector_name", connector_name)])?;
let args = [("connector_name", connector_name)];
let rendered = format_message(locale, question_id, &args);
let rendered = rendered.trim();
if rendered.is_empty() {
return None;
@@ -153,7 +154,7 @@ fn render_tool_params(
let mut handled_names = HashSet::new();
for template_param in template_params {
let label = format_message(locale, &template_param.label_id, &[])?;
let label = format_message(locale, &template_param.label_id, &[]);
let label = label.trim();
if label.is_empty() {
return None;
@@ -235,34 +236,31 @@ mod tests {
})),
);
let rendered = rendered.expect("template should render");
assert_eq!(rendered.question, rendered.elicitation_message);
assert_eq!(rendered.question.contains("Calendar"), true);
assert_eq!(
rendered,
Some(RenderedMcpToolApprovalTemplate {
question: "Allow Calendar to create an event?".to_string(),
elicitation_message: "Allow Calendar to create an event?".to_string(),
tool_params: Some(json!({
"title": "Roadmap review",
"calendar_id": "primary",
"timezone": "UTC",
})),
tool_params_display: vec![
RenderedMcpToolApprovalParam {
name: "calendar_id".to_string(),
value: json!("primary"),
display_name: "Calendar".to_string(),
},
RenderedMcpToolApprovalParam {
name: "title".to_string(),
value: json!("Roadmap review"),
display_name: "Title".to_string(),
},
RenderedMcpToolApprovalParam {
name: "timezone".to_string(),
value: json!("UTC"),
display_name: "timezone".to_string(),
},
],
})
rendered.tool_params,
Some(json!({
"title": "Roadmap review",
"calendar_id": "primary",
"timezone": "UTC",
}))
);
assert_eq!(
rendered
.tool_params_display
.iter()
.map(|param| param.name.as_str())
.collect::<Vec<_>>(),
vec!["calendar_id", "title", "timezone"]
);
assert_eq!(
rendered
.tool_params_display
.last()
.map(|param| param.display_name.as_str()),
Some("timezone")
);
}
@@ -379,46 +377,4 @@ mod tests {
None
);
}
#[test]
fn renders_exact_match_in_chinese() {
let templates = vec![ConsequentialToolMessageTemplate {
connector_id: "google_docs".to_string(),
server_name: "codex_apps".to_string(),
tool_title: "create_document".to_string(),
question_id: "approval-question-google-docs-create-document".to_string(),
template_params: vec![ConsequentialToolTemplateParam {
name: "title".to_string(),
label_id: "approval-param-title".to_string(),
}],
}];
let rendered = render_mcp_tool_approval_template_from_templates(
&templates,
"zh-CN",
"codex_apps",
Some("google_docs"),
Some("Google Docs"),
Some("create_document"),
Some(&json!({
"title": "路线图评审",
})),
);
assert_eq!(
rendered,
Some(RenderedMcpToolApprovalTemplate {
question: "允许 Google Docs 创建文档吗?".to_string(),
elicitation_message: "允许 Google Docs 创建文档吗?".to_string(),
tool_params: Some(json!({
"title": "路线图评审",
})),
tool_params_display: vec![RenderedMcpToolApprovalParam {
name: "title".to_string(),
value: json!("路线图评审"),
display_name: "标题".to_string(),
}],
})
);
}
}
+145 -59
View File
@@ -6,6 +6,7 @@ use codex_app_server_protocol::McpElicitationObjectType;
use codex_app_server_protocol::McpElicitationSchema;
use codex_app_server_protocol::McpServerElicitationRequest;
use codex_app_server_protocol::McpServerElicitationRequestParams;
use codex_i18n::format_message;
use tracing::error;
use crate::analytics_client::AppInvocation;
@@ -319,6 +320,117 @@ fn sanitize_mcp_tool_result_for_model(
})
}
fn arc_monitor_additional_confirmation_message(locale: &str) -> String {
localized_message(locale, "arc-monitor-additional-confirmation", &[])
}
fn arc_monitor_interrupt_message(locale: &str, reason: &str) -> String {
let reason = reason.trim();
if reason.is_empty() {
return localized_message(locale, "approval-safety-cancelled", &[]);
}
localized_message(
locale,
"approval-safety-cancelled-with-reason",
&[("reason", reason)],
)
}
fn mcp_tool_approval_copy(locale: &str) -> McpToolApprovalCopy {
McpToolApprovalCopy {
header: localized_message(locale, "approval-header", &[]),
accept: McpToolApprovalOptionCopy {
label: localized_message(locale, "approval-option-allow", &[]),
description: localized_message(locale, "approval-option-allow-description", &[]),
},
accept_for_session: McpToolApprovalOptionCopy {
label: localized_message(locale, "approval-option-allow-session", &[]),
description: localized_message(
locale,
"approval-option-allow-session-description",
&[],
),
},
accept_and_remember: McpToolApprovalOptionCopy {
label: localized_message(locale, "approval-option-allow-remember", &[]),
description: localized_message(
locale,
"approval-option-allow-remember-description",
&[],
),
},
cancel: McpToolApprovalOptionCopy {
label: localized_message(locale, "approval-option-cancel", &[]),
description: localized_message(locale, "approval-option-cancel-description", &[]),
},
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct McpToolApprovalOptionCopy {
label: String,
description: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct McpToolApprovalCopy {
header: String,
accept: McpToolApprovalOptionCopy,
accept_for_session: McpToolApprovalOptionCopy,
accept_and_remember: McpToolApprovalOptionCopy,
cancel: McpToolApprovalOptionCopy,
}
fn mcp_tool_approval_fallback_message(
locale: &str,
server: &str,
tool_name: &str,
connector_name: Option<&str>,
) -> String {
if let Some(connector_name) = connector_name
.map(str::trim)
.filter(|name| !name.is_empty())
{
return format_message(
locale,
"approval-fallback-question-connector",
&[("connector_name", connector_name), ("tool_name", tool_name)],
);
}
if server == CODEX_APPS_MCP_SERVER_NAME {
return format_message(
locale,
"approval-fallback-question-this-app",
&[("tool_name", tool_name)],
);
}
format_message(
locale,
"approval-fallback-question-server",
&[("server", server), ("tool_name", tool_name)],
)
}
fn mcp_tool_approval_question_text(
locale: &str,
question: String,
monitor_reason: Option<&str>,
) -> String {
match monitor_reason.map(str::trim) {
Some(reason) if !reason.is_empty() => {
localized_message(locale, "approval-monitor-question", &[("reason", reason)])
}
_ => question,
}
}
fn localized_message(locale: &str, message_id: &str, args: &[(&str, &str)]) -> String {
format_message(locale, message_id, args)
}
async fn notify_mcp_tool_call_event(sess: &Session, turn_context: &TurnContext, event: EventMsg) {
sess.send_event(turn_context, event).await;
}
@@ -421,15 +533,11 @@ struct McpToolApprovalElicitationRequest<'a> {
}
pub(crate) const MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX: &str = "mcp_tool_call_approval";
pub(crate) const MCP_TOOL_APPROVAL_ACCEPT: &str = "Allow";
pub(crate) const MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION: &str = "Allow for this session";
// Internal-only token used when guardian auto-reviews delegated MCP approvals on the
// RequestUserInput compatibility path. That legacy MCP prompt has allow/cancel labels but no
// real "Decline" answer, so this lets guardian denials round-trip distinctly from user cancel.
// This is not a user-facing option.
pub(crate) const MCP_TOOL_APPROVAL_DECLINE_SYNTHETIC: &str = "__codex_mcp_decline__";
const MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER: &str = "Allow and don't ask me again";
const MCP_TOOL_APPROVAL_CANCEL: &str = "Cancel";
const MCP_TOOL_APPROVAL_KIND_KEY: &str = "codex_approval_kind";
const MCP_TOOL_APPROVAL_KIND_MCP_TOOL_CALL: &str = "mcp_tool_call";
const MCP_TOOL_APPROVAL_PERSIST_KEY: &str = "persist";
@@ -492,11 +600,15 @@ async fn maybe_request_mcp_tool_approval(
{
ArcMonitorOutcome::Ok => return None,
ArcMonitorOutcome::AskUser(reason) => {
monitor_reason = Some(reason);
monitor_reason = Some(if reason.trim().is_empty() {
arc_monitor_additional_confirmation_message(&turn_context.config.locale)
} else {
reason
});
}
ArcMonitorOutcome::SteerModel(reason) => {
return Some(McpToolApprovalDecision::BlockedBySafetyMonitor(
arc_monitor_interrupt_message(&reason),
arc_monitor_interrupt_message(&turn_context.config.locale, &reason),
));
}
}
@@ -563,6 +675,7 @@ async fn maybe_request_mcp_tool_approval(
.map(|rendered_template| rendered_template.tool_params_display.clone())
.or_else(|| build_mcp_tool_approval_display_params(invocation.arguments.as_ref()));
let mut question = build_mcp_tool_approval_question(
&turn_context.config.locale,
question_id.clone(),
&invocation.server,
&invocation.tool,
@@ -572,8 +685,11 @@ async fn maybe_request_mcp_tool_approval(
.as_ref()
.map(|rendered_template| rendered_template.question.as_str()),
);
question.question =
mcp_tool_approval_question_text(question.question, monitor_reason.as_deref());
question.question = mcp_tool_approval_question_text(
&turn_context.config.locale,
question.question,
monitor_reason.as_deref(),
);
if tool_call_mcp_elicitation_enabled {
let request_id = rmcp::model::RequestId::String(
format!("{MCP_TOOL_APPROVAL_QUESTION_ID_PREFIX}_{call_id}").into(),
@@ -599,6 +715,7 @@ async fn maybe_request_mcp_tool_approval(
},
);
let decision = parse_mcp_tool_approval_elicitation_response(
&turn_context.config.locale,
sess.request_mcp_server_elicitation(turn_context.as_ref(), request_id, params)
.await,
&question_id,
@@ -622,7 +739,7 @@ async fn maybe_request_mcp_tool_approval(
.request_user_input(turn_context.as_ref(), call_id.to_string(), args)
.await;
let decision = normalize_approval_decision_for_mode(
parse_mcp_tool_approval_response(response, &question_id),
parse_mcp_tool_approval_response(&turn_context.config.locale, response, &question_id),
approval_mode,
);
apply_mcp_tool_approval_decision(
@@ -821,6 +938,7 @@ async fn lookup_mcp_app_usage_metadata(
}
fn build_mcp_tool_approval_question(
locale: &str,
question_id: String,
server: &str,
tool_name: &str,
@@ -828,37 +946,38 @@ fn build_mcp_tool_approval_question(
prompt_options: McpToolApprovalPromptOptions,
question_override: Option<&str>,
) -> RequestUserInputQuestion {
let approval_copy = mcp_tool_approval_copy(locale);
let question = question_override
.map(ToString::to_string)
.unwrap_or_else(|| {
build_mcp_tool_approval_fallback_message(server, tool_name, connector_name)
mcp_tool_approval_fallback_message(locale, server, tool_name, connector_name)
});
let question = format!("{}?", question.trim_end_matches('?'));
let mut options = vec![RequestUserInputQuestionOption {
label: MCP_TOOL_APPROVAL_ACCEPT.to_string(),
description: "Run the tool and continue.".to_string(),
label: approval_copy.accept.label.clone(),
description: approval_copy.accept.description,
}];
if prompt_options.allow_session_remember {
options.push(RequestUserInputQuestionOption {
label: MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string(),
description: "Run the tool and remember this choice for this session.".to_string(),
label: approval_copy.accept_for_session.label.clone(),
description: approval_copy.accept_for_session.description,
});
}
if prompt_options.allow_persistent_approval {
options.push(RequestUserInputQuestionOption {
label: MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER.to_string(),
description: "Run the tool and remember this choice for future tool calls.".to_string(),
label: approval_copy.accept_and_remember.label.clone(),
description: approval_copy.accept_and_remember.description,
});
}
options.push(RequestUserInputQuestionOption {
label: MCP_TOOL_APPROVAL_CANCEL.to_string(),
description: "Cancel this tool call.".to_string(),
label: approval_copy.cancel.label,
description: approval_copy.cancel.description,
});
RequestUserInputQuestion {
id: question_id,
header: "Approve app tool call?".to_string(),
header: approval_copy.header,
question,
is_other: false,
is_secret: false,
@@ -866,43 +985,6 @@ fn build_mcp_tool_approval_question(
}
}
fn build_mcp_tool_approval_fallback_message(
server: &str,
tool_name: &str,
connector_name: Option<&str>,
) -> String {
let actor = connector_name
.map(str::trim)
.filter(|name| !name.is_empty())
.map(ToString::to_string)
.unwrap_or_else(|| {
if server == CODEX_APPS_MCP_SERVER_NAME {
"this app".to_string()
} else {
format!("the {server} MCP server")
}
});
format!("Allow {actor} to run tool \"{tool_name}\"?")
}
fn mcp_tool_approval_question_text(question: String, monitor_reason: Option<&str>) -> String {
match monitor_reason.map(str::trim) {
Some(reason) if !reason.is_empty() => {
format!("Tool call needs your approval. Reason: {reason}")
}
_ => question,
}
}
fn arc_monitor_interrupt_message(reason: &str) -> String {
let reason = reason.trim();
if reason.is_empty() {
"Tool call was cancelled because of safety risks.".to_string()
} else {
format!("Tool call was cancelled because of safety risks: {reason}")
}
}
fn build_mcp_tool_approval_elicitation_request(
sess: &Session,
turn_context: &TurnContext,
@@ -1053,6 +1135,7 @@ fn build_mcp_tool_approval_display_params(
}
fn parse_mcp_tool_approval_elicitation_response(
locale: &str,
response: Option<ElicitationResponse>,
question_id: &str,
) -> McpToolApprovalDecision {
@@ -1078,6 +1161,7 @@ fn parse_mcp_tool_approval_elicitation_response(
}
match parse_mcp_tool_approval_response(
locale,
request_user_input_response_from_elicitation_content(response.content),
question_id,
) {
@@ -1118,9 +1202,11 @@ fn request_user_input_response_from_elicitation_content(
}
fn parse_mcp_tool_approval_response(
locale: &str,
response: Option<RequestUserInputResponse>,
question_id: &str,
) -> McpToolApprovalDecision {
let approval_copy = mcp_tool_approval_copy(locale);
let Some(response) = response else {
return McpToolApprovalDecision::Cancel;
};
@@ -1138,17 +1224,17 @@ fn parse_mcp_tool_approval_response(
McpToolApprovalDecision::Decline
} else if answers
.iter()
.any(|answer| answer == MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION)
.any(|answer| answer == &approval_copy.accept_for_session.label)
{
McpToolApprovalDecision::AcceptForSession
} else if answers
.iter()
.any(|answer| answer == MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER)
.any(|answer| answer == &approval_copy.accept_and_remember.label)
{
McpToolApprovalDecision::AcceptAndRemember
} else if answers
.iter()
.any(|answer| answer == MCP_TOOL_APPROVAL_ACCEPT)
.any(|answer| answer == &approval_copy.accept.label)
{
McpToolApprovalDecision::Accept
} else {
+54 -31
View File
@@ -7,6 +7,7 @@ use crate::config::types::AppToolConfig;
use crate::config::types::AppToolsConfig;
use crate::config::types::AppsConfigToml;
use codex_config::CONFIG_TOML_FILE;
use codex_i18n::DEFAULT_LOCALE;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_response_created;
@@ -61,6 +62,10 @@ fn prompt_options(
}
}
fn approval_copy() -> McpToolApprovalCopy {
mcp_tool_approval_copy(DEFAULT_LOCALE)
}
#[test]
fn approval_required_when_read_only_false_and_destructive() {
let annotations = annotations(Some(false), Some(true), None);
@@ -99,19 +104,24 @@ fn prompt_mode_does_not_allow_persistent_remember() {
#[test]
fn approval_question_text_prepends_safety_reason() {
assert_eq!(
mcp_tool_approval_question_text(
"Allow this action?".to_string(),
Some("This tool may contact an external system."),
),
"Tool call needs your approval. Reason: This tool may contact an external system."
let rendered = mcp_tool_approval_question_text(
DEFAULT_LOCALE,
"Allow this action?".to_string(),
Some("This tool may contact an external system."),
);
assert_eq!(
rendered.contains("This tool may contact an external system."),
true
);
assert_ne!(rendered, "Allow this action?".to_string());
}
#[tokio::test]
async fn approval_elicitation_request_uses_message_override_and_preserves_tool_params_keys() {
let (session, turn_context) = make_session_and_context().await;
let question = build_mcp_tool_approval_question(
DEFAULT_LOCALE,
"q".to_string(),
CODEX_APPS_MCP_SERVER_NAME,
"create_event",
@@ -205,6 +215,7 @@ async fn approval_elicitation_request_uses_message_override_and_preserves_tool_p
#[test]
fn custom_mcp_tool_question_mentions_server_name() {
let question = build_mcp_tool_approval_question(
DEFAULT_LOCALE,
"q".to_string(),
"custom_server",
"run_action",
@@ -213,7 +224,7 @@ fn custom_mcp_tool_question_mentions_server_name() {
None,
);
assert_eq!(question.header, "Approve app tool call?");
assert_eq!(question.header, approval_copy().header);
assert_eq!(
question.question,
"Allow the custom_server MCP server to run tool \"run_action\"?"
@@ -224,13 +235,14 @@ fn custom_mcp_tool_question_mentions_server_name() {
.expect("options")
.into_iter()
.map(|option| option.label)
.any(|label| label == MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER)
.any(|label| label == approval_copy().accept_and_remember.label)
);
}
#[test]
fn codex_apps_tool_question_uses_fallback_app_label() {
let question = build_mcp_tool_approval_question(
DEFAULT_LOCALE,
"q".to_string(),
CODEX_APPS_MCP_SERVER_NAME,
"run_action",
@@ -248,6 +260,7 @@ fn codex_apps_tool_question_uses_fallback_app_label() {
#[test]
fn trusted_codex_apps_tool_question_offers_always_allow() {
let question = build_mcp_tool_approval_question(
DEFAULT_LOCALE,
"q".to_string(),
CODEX_APPS_MCP_SERVER_NAME,
"run_action",
@@ -256,14 +269,15 @@ fn trusted_codex_apps_tool_question_offers_always_allow() {
None,
);
let options = question.options.expect("options");
let approval_copy = approval_copy();
assert!(options.iter().any(|option| {
option.label == MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION
&& option.description == "Run the tool and remember this choice for this session."
option.label == approval_copy.accept_for_session.label
&& option.description == approval_copy.accept_for_session.description
}));
assert!(options.iter().any(|option| {
option.label == MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER
&& option.description == "Run the tool and remember this choice for future tool calls."
option.label == approval_copy.accept_and_remember.label
&& option.description == approval_copy.accept_and_remember.description
}));
assert_eq!(
options
@@ -271,10 +285,10 @@ fn trusted_codex_apps_tool_question_offers_always_allow() {
.map(|option| option.label)
.collect::<Vec<_>>(),
vec![
MCP_TOOL_APPROVAL_ACCEPT.to_string(),
MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string(),
MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER.to_string(),
MCP_TOOL_APPROVAL_CANCEL.to_string(),
approval_copy.accept.label,
approval_copy.accept_for_session.label,
approval_copy.accept_and_remember.label,
approval_copy.cancel.label,
]
);
}
@@ -288,6 +302,7 @@ fn codex_apps_tool_question_without_elicitation_omits_always_allow() {
};
let persistent_key = session_key.clone();
let question = build_mcp_tool_approval_question(
DEFAULT_LOCALE,
"q".to_string(),
CODEX_APPS_MCP_SERVER_NAME,
"run_action",
@@ -304,9 +319,9 @@ fn codex_apps_tool_question_without_elicitation_omits_always_allow() {
.map(|option| option.label)
.collect::<Vec<_>>(),
vec![
MCP_TOOL_APPROVAL_ACCEPT.to_string(),
MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string(),
MCP_TOOL_APPROVAL_CANCEL.to_string(),
approval_copy().accept.label,
approval_copy().accept_for_session.label,
approval_copy().cancel.label,
]
);
}
@@ -314,6 +329,7 @@ fn codex_apps_tool_question_without_elicitation_omits_always_allow() {
#[test]
fn custom_mcp_tool_question_offers_session_remember_without_always_allow() {
let question = build_mcp_tool_approval_question(
DEFAULT_LOCALE,
"q".to_string(),
"custom_server",
"run_action",
@@ -330,9 +346,9 @@ fn custom_mcp_tool_question_offers_session_remember_without_always_allow() {
.map(|option| option.label)
.collect::<Vec<_>>(),
vec![
MCP_TOOL_APPROVAL_ACCEPT.to_string(),
MCP_TOOL_APPROVAL_ACCEPT_FOR_SESSION.to_string(),
MCP_TOOL_APPROVAL_CANCEL.to_string(),
approval_copy().accept.label,
approval_copy().accept_for_session.label,
approval_copy().cancel.label,
]
);
}
@@ -474,9 +490,10 @@ fn codex_apps_tool_call_request_meta_includes_codex_apps_meta() {
#[test]
fn accepted_elicitation_content_converts_to_request_user_input_response() {
let approval_copy = approval_copy();
let response = request_user_input_response_from_elicitation_content(Some(serde_json::json!(
{
"approval": MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER,
"approval": approval_copy.accept_and_remember.label,
}
)));
@@ -486,7 +503,7 @@ fn accepted_elicitation_content_converts_to_request_user_input_response() {
answers: std::collections::HashMap::from([(
"approval".to_string(),
RequestUserInputAnswer {
answers: vec![MCP_TOOL_APPROVAL_ACCEPT_AND_REMEMBER.to_string()],
answers: vec![approval_copy.accept_and_remember.label],
},
)]),
})
@@ -743,10 +760,11 @@ fn approval_elicitation_meta_merges_session_and_always_persist_with_connector_so
#[test]
fn declined_elicitation_response_stays_decline() {
let response = parse_mcp_tool_approval_elicitation_response(
DEFAULT_LOCALE,
Some(ElicitationResponse {
action: ElicitationAction::Decline,
content: Some(serde_json::json!({
"approval": MCP_TOOL_APPROVAL_ACCEPT,
"approval": approval_copy().accept.label,
})),
meta: None,
}),
@@ -759,6 +777,7 @@ fn declined_elicitation_response_stays_decline() {
#[test]
fn synthetic_decline_request_user_input_response_stays_decline() {
let response = parse_mcp_tool_approval_response(
DEFAULT_LOCALE,
Some(RequestUserInputResponse {
answers: HashMap::from([(
"approval".to_string(),
@@ -776,6 +795,7 @@ fn synthetic_decline_request_user_input_response_stays_decline() {
#[test]
fn accepted_elicitation_response_uses_always_persist_meta() {
let response = parse_mcp_tool_approval_elicitation_response(
DEFAULT_LOCALE,
Some(ElicitationResponse {
action: ElicitationAction::Accept,
content: None,
@@ -792,6 +812,7 @@ fn accepted_elicitation_response_uses_always_persist_meta() {
#[test]
fn accepted_elicitation_response_uses_session_persist_meta() {
let response = parse_mcp_tool_approval_elicitation_response(
DEFAULT_LOCALE,
Some(ElicitationResponse {
action: ElicitationAction::Accept,
content: None,
@@ -808,6 +829,7 @@ fn accepted_elicitation_response_uses_session_persist_meta() {
#[test]
fn accepted_elicitation_without_content_defaults_to_accept() {
let response = parse_mcp_tool_approval_elicitation_response(
DEFAULT_LOCALE,
Some(ElicitationResponse {
action: ElicitationAction::Accept,
content: None,
@@ -991,12 +1013,13 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() {
)
.await;
assert_eq!(
decision,
Some(McpToolApprovalDecision::BlockedBySafetyMonitor(
"Tool call was cancelled because of safety risks: high-risk action".to_string(),
))
);
match decision {
Some(McpToolApprovalDecision::BlockedBySafetyMonitor(message)) => {
assert_eq!(message.contains("high-risk action"), true);
assert_eq!(message.is_empty(), false);
}
other => panic!("expected BlockedBySafetyMonitor, got {other:?}"),
}
}
#[tokio::test]
+2 -1
View File
@@ -3,5 +3,6 @@ load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "i18n",
crate_name = "codex_i18n",
compile_data = glob(["src/locales/**"]),
lib_data_extra = glob(["src/locales/**"]),
test_data_extra = glob(["src/locales/**"]),
)
+1 -1
View File
@@ -12,8 +12,8 @@ path = "src/lib.rs"
workspace = true
[dependencies]
codex-utils-cargo-bin = { workspace = true }
fluent-bundle = { workspace = true }
include_dir = { workspace = true }
sys-locale = { workspace = true }
thiserror = { workspace = true }
unic-langid = { workspace = true }
+133 -65
View File
@@ -1,15 +1,18 @@
use std::collections::HashMap;
use std::ffi::OsStr;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::sync::LazyLock;
use fluent_bundle::FluentArgs;
use fluent_bundle::FluentResource;
use fluent_bundle::concurrent::FluentBundle;
use include_dir::Dir;
use include_dir::include_dir;
use thiserror::Error;
use unic_langid::LanguageIdentifier;
const LOCALES_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/locales");
const LOCALES_DIR: &str = "src/locales";
pub const DEFAULT_LOCALE: &str = "en-US";
type Bundle = FluentBundle<FluentResource>;
@@ -20,9 +23,13 @@ pub enum LocaleError {
InvalidLocale(String),
}
static SUPPORTED_LOCALES: LazyLock<Vec<String>> = LazyLock::new(load_supported_locales);
struct LocaleResources {
supported_locales: Vec<String>,
bundles: HashMap<String, Bundle>,
}
static BUNDLES: LazyLock<HashMap<String, Bundle>> = LazyLock::new(load_bundles);
static LOCALE_RESOURCES: LazyLock<Option<LocaleResources>> =
LazyLock::new(|| load_locale_resources().ok());
pub fn resolve_locale(config_locale: Option<&str>) -> Result<String, LocaleError> {
resolve_locale_with_system(config_locale, sys_locale::get_locale().as_deref())
@@ -42,15 +49,21 @@ pub fn resolve_locale_with_system(
Ok(negotiate_locale(requested.as_ref()))
}
pub fn format_message(locale: &str, message_id: &str, args: &[(&str, &str)]) -> Option<String> {
let bundle = BUNDLES
.get(locale)
.or_else(|| BUNDLES.get(DEFAULT_LOCALE))?;
render_message(bundle, message_id, args).or_else(|| {
BUNDLES
.get(DEFAULT_LOCALE)
.and_then(|fallback| render_message(fallback, message_id, args))
})
pub fn format_message(locale: &str, message_id: &str, args: &[(&str, &str)]) -> String {
let rendered = LOCALE_RESOURCES.as_ref().and_then(|locale_resources| {
locale_resources
.bundles
.get(locale)
.and_then(|bundle| render_message(bundle, message_id, args))
.or_else(|| {
(locale != DEFAULT_LOCALE)
.then_some(())
.and_then(|()| locale_resources.bundles.get(DEFAULT_LOCALE))
.and_then(|bundle| render_message(bundle, message_id, args))
})
});
rendered.unwrap_or_else(|| message_id.to_string())
}
fn parse_locale(value: &str) -> Option<LanguageIdentifier> {
@@ -75,62 +88,93 @@ fn negotiate_locale(requested: Option<&LanguageIdentifier>) -> String {
};
let exact = requested.to_string();
if SUPPORTED_LOCALES.iter().any(|locale| locale == &exact) {
if supported_locales().iter().any(|locale| locale == &exact) {
return exact;
}
let requested_language = requested.language.as_str();
SUPPORTED_LOCALES
supported_locales()
.iter()
.find(|locale| locale.split('-').next() == Some(requested_language))
.cloned()
.unwrap_or_else(|| DEFAULT_LOCALE.to_string())
}
fn load_supported_locales() -> Vec<String> {
let mut locales = LOCALES_DIR
.dirs()
.filter_map(|dir| dir.path().file_name())
.filter_map(|name| name.to_str())
.map(ToString::to_string)
.collect::<Vec<String>>();
locales.sort();
locales
fn supported_locales() -> &'static [String] {
LOCALE_RESOURCES
.as_ref()
.map(|locale_resources| locale_resources.supported_locales.as_slice())
.unwrap_or(&[])
}
fn load_bundles() -> HashMap<String, Bundle> {
fn load_locale_resources() -> io::Result<LocaleResources> {
let locales_dir = resolve_locales_dir()?;
let mut supported_locales = Vec::new();
let mut bundles = HashMap::new();
for dir in LOCALES_DIR.dirs() {
let Some(locale_name) = dir.path().file_name().and_then(|name| name.to_str()) else {
for entry in sorted_dir_entries(&locales_dir)? {
if !entry.file_type()?.is_dir() {
continue;
};
let Some(language_identifier) = parse_locale(locale_name) else {
continue;
};
let mut bundle = FluentBundle::new_concurrent(vec![language_identifier]);
bundle.set_use_isolating(false);
for file in dir.files() {
if file.path().extension().and_then(|value| value.to_str()) != Some("ftl") {
continue;
}
let Some(contents) = file.contents_utf8() else {
panic!("locale files must be valid UTF-8");
};
let Ok(resource) = FluentResource::try_new(contents.to_string()) else {
panic!("locale files must be valid Fluent resources");
};
if bundle.add_resource(resource).is_err() {
panic!("locale resources must not define duplicate messages");
}
}
bundles.insert(locale_name.to_string(), bundle);
let locale_name = entry.file_name().to_string_lossy().to_string();
let Some(language_identifier) = parse_locale(&locale_name) else {
continue;
};
let bundle = load_bundle(&entry.path(), &locale_name, language_identifier)?;
supported_locales.push(locale_name.clone());
bundles.insert(locale_name, bundle);
}
bundles
supported_locales.sort();
Ok(LocaleResources {
supported_locales,
bundles,
})
}
fn resolve_locales_dir() -> io::Result<PathBuf> {
codex_utils_cargo_bin::find_resource!(LOCALES_DIR)
}
fn load_bundle(
locale_dir: &Path,
locale_name: &str,
language_identifier: LanguageIdentifier,
) -> io::Result<Bundle> {
let mut bundle = FluentBundle::new_concurrent(vec![language_identifier]);
bundle.set_use_isolating(false);
for entry in sorted_dir_entries(locale_dir)? {
if !entry.file_type()?.is_file() || entry.path().extension() != Some(OsStr::new("ftl")) {
continue;
}
let resource_path = entry.path();
let contents = fs::read_to_string(&resource_path)?;
let resource = FluentResource::try_new(contents).map_err(|(_, errors)| {
io::Error::other(format!(
"invalid Fluent resource `{}`: {errors:?}",
resource_path.display()
))
})?;
if bundle.add_resource(resource).is_err() {
return Err(io::Error::other(format!(
"locale `{locale_name}` defines duplicate messages in `{}`",
resource_path.display()
)));
}
}
Ok(bundle)
}
fn sorted_dir_entries(dir: &Path) -> io::Result<Vec<fs::DirEntry>> {
let mut entries = fs::read_dir(dir)?.collect::<Result<Vec<_>, _>>()?;
entries.sort_by_key(fs::DirEntry::file_name);
Ok(entries)
}
fn render_message(bundle: &Bundle, message_id: &str, args: &[(&str, &str)]) -> Option<String> {
@@ -199,26 +243,50 @@ mod tests {
}
#[test]
fn formats_english_message() {
fn formats_message_with_interpolation() {
let rendered = format_message(
"en-US",
"approval-question-google-docs-create-document",
&[("connector_name", "Google Docs")],
);
assert_eq!(rendered.contains("Google Docs"), true);
assert_eq!(rendered.is_empty(), false);
}
#[test]
fn different_locales_can_render_same_message() {
let english = format_message(
"en-US",
"approval-question-google-docs-create-document",
&[("connector_name", "Google Docs")],
);
let chinese = format_message(
"zh-CN",
"approval-question-google-docs-create-document",
&[("connector_name", "Google Docs")],
);
assert_eq!(english.contains("Google Docs"), true);
assert_eq!(chinese.contains("Google Docs"), true);
assert_ne!(english, chinese);
}
#[test]
fn format_message_falls_back_to_english_message() {
let english = format_message("en-US", "approval-option-allow", &[]);
assert_eq!(
format_message(
"en-US",
"approval-question-google-docs-create-document",
&[("connector_name", "Google Docs")]
),
Some("Allow Google Docs to create a document?".to_string())
format_message("fr-FR", "approval-option-allow", &[]),
english
);
}
#[test]
fn formats_chinese_message() {
fn format_message_returns_message_id_when_message_is_missing() {
assert_eq!(
format_message(
"zh-CN",
"approval-question-google-docs-create-document",
&[("connector_name", "Google Docs")]
),
Some("允许 Google Docs 创建文档吗?".to_string())
format_message("fr-FR", "approval-option-missing", &[]),
"approval-option-missing".to_string()
);
}
}
@@ -1,3 +1,19 @@
approval-header = Approve app tool call?
approval-option-allow = Allow
approval-option-allow-description = Run the tool and continue.
approval-option-allow-session = Allow for this session
approval-option-allow-session-description = Run the tool and remember this choice for this session.
approval-option-allow-remember = Allow and don't ask me again
approval-option-allow-remember-description = Run the tool and remember this choice for future tool calls.
approval-option-cancel = Cancel
approval-option-cancel-description = Cancel this tool call.
approval-fallback-question-connector = Allow {$connector_name} to run tool "{$tool_name}"?
approval-fallback-question-this-app = Allow this app to run tool "{$tool_name}"?
approval-fallback-question-server = Allow the {$server} MCP server to run tool "{$tool_name}"?
approval-monitor-question = Tool call needs your approval. Reason: {$reason}
approval-safety-cancelled = Tool call was cancelled because of safety risks.
approval-safety-cancelled-with-reason = Tool call was cancelled because of safety risks: {$reason}
approval-param-action = Action
approval-param-attendees = Attendees
approval-param-base-branch = Base branch
@@ -0,0 +1 @@
arc-monitor-additional-confirmation = Additional confirmation is required before this tool call can continue.
@@ -1,3 +1,19 @@
approval-header = 批准应用工具调用?
approval-option-allow = 允许
approval-option-allow-description = 运行该工具并继续。
approval-option-allow-session = 在此会话中允许
approval-option-allow-session-description = 运行该工具,并在此会话中记住这个选择。
approval-option-allow-remember = 允许,不再询问
approval-option-allow-remember-description = 运行该工具,并在未来的工具调用中记住这个选择。
approval-option-cancel = 取消
approval-option-cancel-description = 取消此次工具调用。
approval-fallback-question-connector = 允许 {$connector_name} 运行工具 "{$tool_name}" 吗?
approval-fallback-question-this-app = 允许此应用运行工具 "{$tool_name}" 吗?
approval-fallback-question-server = 允许 {$server} MCP 服务器运行工具 "{$tool_name}" 吗?
approval-monitor-question = 此工具调用需要你的批准。原因:{$reason}
approval-safety-cancelled = 该工具调用因安全风险已被取消。
approval-safety-cancelled-with-reason = 该工具调用因安全风险已被取消:{$reason}
approval-param-action = 操作
approval-param-attendees = 参与者
approval-param-base-branch = 目标分支
@@ -0,0 +1 @@
arc-monitor-additional-confirmation = 继续此工具调用前需要额外确认。