saved_path mod + filesystem integrity risk patch

This commit is contained in:
won
2026-03-05 12:34:41 -08:00
parent 593d6bca6b
commit e66745c68b
6 changed files with 59 additions and 17 deletions

View File

@@ -153,6 +153,7 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option<TurnItem> {
status: status.clone(),
revised_prompt: revised_prompt.clone(),
result: result.clone(),
saved_path: None,
},
)),
_ => None,

View File

@@ -64,11 +64,19 @@ async fn save_image_generation_result_to_cwd(
.map_err(|err| {
CodexErr::InvalidRequest(format!("invalid image generation payload: {err}"))
})?;
let file_stem = if call_id.is_empty() {
"generated_image"
} else {
call_id
};
let mut file_stem: String = call_id
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch
} else {
'_'
}
})
.collect();
if file_stem.is_empty() {
file_stem = "generated_image".to_string();
}
let path = cwd.join(format!("{file_stem}.png"));
tokio::fs::write(&path, bytes).await?;
Ok(path)
@@ -181,16 +189,20 @@ pub(crate) async fn handle_output_item_done(
}
// No tool call: convert messages/reasoning into turn items and mark them as complete.
Ok(None) => {
if let Some(mut turn_item) = handle_non_tool_response_item(&item, plan_mode) {
if let TurnItem::ImageGeneration(image_item) = &mut turn_item {
let path = save_image_generation_result_to_cwd(
&ctx.turn_context.cwd,
&image_item.id,
&image_item.result,
)
.await?;
image_item.result = path.to_string_lossy().into_owned();
}
if let Some(turn_item) = handle_non_tool_response_item(&item, plan_mode) {
let turn_item = match turn_item {
TurnItem::ImageGeneration(mut image_item) => {
let path = save_image_generation_result_to_cwd(
&ctx.turn_context.cwd,
&image_item.id,
&image_item.result,
)
.await?;
image_item.saved_path = Some(path.to_string_lossy().into_owned());
TurnItem::ImageGeneration(image_item)
}
other => other,
};
if previously_active_item.is_none() {
let mut started_item = turn_item.clone();
@@ -198,6 +210,7 @@ pub(crate) async fn handle_output_item_done(
item.status = "in_progress".to_string();
item.revised_prompt = None;
item.result.clear();
item.saved_path = None;
}
ctx.sess
.emit_turn_item_started(&ctx.turn_context, &started_item)
@@ -469,6 +482,22 @@ mod tests {
assert_eq!(std::fs::read(saved_path).expect("saved file"), b"foo");
}
#[tokio::test]
async fn save_image_generation_result_sanitizes_call_id_for_output_path() {
let dir = tempdir().expect("tempdir");
let saved_path = save_image_generation_result_to_cwd(dir.path(), "../ig/..", "Zm9v")
.await
.expect("image should be saved");
assert_eq!(saved_path.parent(), Some(dir.path()));
assert_eq!(
saved_path.file_name().and_then(|v| v.to_str()),
Some("___ig___.png")
);
assert_eq!(std::fs::read(saved_path).expect("saved file"), b"foo");
}
#[tokio::test]
async fn save_image_generation_result_rejects_non_standard_base64() {
let dir = tempdir().expect("tempdir");

View File

@@ -303,10 +303,11 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> {
assert_eq!(end.call_id, "ig_123");
assert_eq!(end.status, "completed");
assert_eq!(end.revised_prompt, Some("A tiny blue square".to_string()));
assert_eq!(end.result, "Zm9v");
let expected_saved_path = cwd.path().join("ig_123.png");
assert_eq!(
end.result,
expected_saved_path.to_string_lossy().into_owned()
end.saved_path,
Some(expected_saved_path.to_string_lossy().into_owned())
);
assert_eq!(std::fs::read(expected_saved_path)?, b"foo");

View File

@@ -89,6 +89,9 @@ pub struct ImageGenerationItem {
#[ts(optional)]
pub revised_prompt: Option<String>,
pub result: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub saved_path: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)]
@@ -254,6 +257,7 @@ impl ImageGenerationItem {
status: self.status.clone(),
revised_prompt: self.revised_prompt.clone(),
result: self.result.clone(),
saved_path: self.saved_path.clone(),
})
}
}

View File

@@ -1898,6 +1898,9 @@ pub struct ImageGenerationEndEvent {
#[ts(optional)]
pub revised_prompt: Option<String>,
pub result: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub saved_path: Option<String>,
}
// Conversation kept for backward compatibility.
@@ -3270,6 +3273,7 @@ mod tests {
status: "in_progress".into(),
revised_prompt: None,
result: String::new(),
saved_path: None,
}),
};
@@ -3291,6 +3295,7 @@ mod tests {
status: "completed".into(),
revised_prompt: Some("A tiny blue square".into()),
result: "Zm9v".into(),
saved_path: Some("/tmp/ig-1.png".into()),
}),
};
@@ -3302,6 +3307,7 @@ mod tests {
assert_eq!(event.status, "completed");
assert_eq!(event.revised_prompt.as_deref(), Some("A tiny blue square"));
assert_eq!(event.result, "Zm9v");
assert_eq!(event.saved_path.as_deref(), Some("/tmp/ig-1.png"));
}
_ => panic!("expected ImageGenerationEnd event"),
}

View File

@@ -6053,6 +6053,7 @@ async fn image_generation_call_adds_history_cell() {
status: "completed".into(),
revised_prompt: Some("A tiny blue square".into()),
result: "Zm9v".into(),
saved_path: None,
}),
});