对齐 Codex app-server 读写单行上限:写侧守卫 + DirectProject 历史注入前置校验,并把注入超限改判为不可重试

- 隔离探针实测(codex-cli 0.147.0,AGC 捆绑版本):experimentalRawEvents 线程收到 thread/inject_items 后会逐条原样回显 rawResponseItem/completed —— 注入一个 5 MiB 的 item,stdout 就回一条 5 243 245 字节的单行;而读侧上限原为 4 MiB,于是「我们注入得进去」却「我们读不回来」,报错方向指向 app-server,实际是我方读行判死
- codex_app_server.rs 的 GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES 由 4 MiB 提到 32 MiB,注明它同时充当 stdout 读侧上限与 stdin 写侧守卫;取值依据写进注释(单张图 base64 上限 10 MiB、单次图片总量 16 MiB 折 base64 约 21.3 MiB,再加 JSON 信封),再大就失去内存/DoS 边界的意义
- 新增写侧守卫 game_creator_codex_app_server_message_oversize_error:write_message 超限即失败关闭并给出字节数,绝不写出自己读不回来的行(两侧共用同一常量即这条不变量)
- 新增 DirectProject 历史注入前置校验 direct_project_history_injection_oversize_error:单条 item(扣掉 rawResponseItem/completed 回显信封余量)与整份载荷都必须落在上限内;超限指名 itemId/type/字节数并失败关闭,且不截断、不摘要、不改写历史(技术方案末节口径)
- 新增专属前缀 DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX,供 direct_runtime 判可重试性与给专属提示
- direct_runtime.rs:direct_codex_failure_is_retryable 把注入超限改为不可重试(同一份历史每次读结论相同,且本轮用户消息已先追加进同一文件、载荷只会更大);direct_codex_failure_recovery_hint 与 direct_codex_failure_public_summary 各补一条专属文案,用户不再看到笼统的「Codex 未完成本轮代码修改,请检查运行时配置后重试(可直接重试)」
- 新增 2 条用例:写侧守卫与读侧共用同一上限的边界(等于上限必须放行、上限+1 必须拒绝)、注入侧整份载荷与单条 item 双超限必须拒绝
- 变异验证:把写侧守卫改成 >=、把单条上限放成 usize::MAX,两条用例各在自己那一处变红;还原后逐字节哈希一致并复跑绿灯
- 门禁:npm run check:rustfmt exit 0;npm run typecheck exit 0;cargo test 定向 2 passed(2257 filtered out)
This commit is contained in:
2026-09-11 22:24:30 +08:00
parent 1b7261685c
commit 3db6afa052
2 changed files with 178 additions and 7 deletions
@@ -16,7 +16,21 @@ const GAME_CREATOR_CODEX_APP_SERVER_REMOTE_CONTROL_DISABLED_ENV: &str =
"CODEX_INTERNAL_APP_SERVER_REMOTE_CONTROL_DISABLED";
const GAME_CREATOR_CODEX_AUTH_BRIDGE_API_BASE_URL: &str = "https://api.openai.com/v1";
const GAME_CREATOR_CODEX_APP_SERVER_PROTOCOL: &str = "genarrative-codex-app-server.v3";
const GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES: usize = 4 * 1024 * 1024;
/// 单条 JSON-RPC 消息(NDJSON 一行)的字节上限:**同时**是 stdout 读侧上限与 stdin 写侧守卫。
///
/// 两侧必须共用这一个常量。2026-09-11 的隔离探针实测(codex-cli 0.147.0AGC 捆绑版本):
/// `experimentalRawEvents: true` 的线程收到 `thread/inject_items` 后,会**逐条原样回显**
/// `rawResponseItem/completed` —— 注入一个 5 MiB 的 itemstdout 就回一条 5 243 245 字节的单行。
/// 因此「我们注入得进去」就必须「我们读得回来」;两侧取不同值(曾为读侧 4 MiB、图片单张 5 MiB/
/// 单次 16 MiB)会让一次**原本合法**的注入在随后的读行里被误判成连接级故障。
///
/// 32 MiB 的依据:单张图 base64 data URL 上限 10 MiB`..._IMAGE_MAX_BYTES * 2`)、单次请求图片
/// 总量上限 16 MiB`..._IMAGE_TOTAL_MAX_BYTES`base64 后约 21.3 MiB+ JSON 信封,取 32 MiB
/// 覆盖现有写侧全部单一消息;再大就失去内存/DoS 边界的意义(每连接单行最多驻留该数量级)。
const GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES: usize = 32 * 1024 * 1024;
/// 回显信封余量:item 会被包进 `{"method":"rawResponseItem/completed","params":{…,"item":…}}`
/// 之后再输出,所以**单条 item** 允许占用的字节数必须小于整行上限。
const GAME_CREATOR_CODEX_APP_SERVER_ECHO_ENVELOPE_MARGIN_BYTES: usize = 8 * 1024;
const GAME_CREATOR_CODEX_APP_SERVER_STDERR_RECORD_MAX_BYTES: usize = 256 * 1024;
const GAME_CREATOR_CODEX_APP_SERVER_AUTH_MAX_BYTES: usize = 1024 * 1024;
const GAME_CREATOR_CODEX_APP_SERVER_BACKLOG_TURN_MAX: usize = 128;
@@ -44,6 +58,13 @@ pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX
"codex-app-server-terminal-unknown:";
pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX: &str =
"codex-app-server-error:";
/// DirectProject 历史注入超限:专属前缀,供 `direct_runtime.rs` 判「可重试性」与给专属恢复提示。
///
/// 命中它意味着**同一份历史每次都会得到同一结论**(历史文件不变则载荷不变),所以按不可重试
/// 处理,与 `DIRECT_PROJECT_HISTORY_SHAPE_FAILURE_MARKERS` 同口径(见技术方案
/// `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md` 末节)。
pub(in crate::agent) const DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX: &str =
"DirectProject 历史注入载荷超过单行上限";
const CODEX_APP_SERVER_BASE_INSTRUCTIONS_FALLBACK: &str =
"You are Codex working directly in the user's Genarrative game project. Follow the AGC system instructions, inspect and modify files in the current workspace when needed, and report concrete progress and failures. Do not invent completion evidence.";
const DIRECT_CODEX_BASE_INSTRUCTIONS_FALLBACK: &str =
@@ -2296,6 +2317,11 @@ impl CodexAppServerConnection {
let mut bytes = serde_json::to_vec(value)
.map_err(|error| format!("序列化 Codex app-server JSON-RPC 失败:{error}"))?;
bytes.push(b'\n');
// 写侧守卫:绝不写出自己读不回来的行(两侧共用 `..._LINE_MAX_BYTES`)。
// 失败关闭并给出字节数,**不做任何截断**。
if let Some(error) = game_creator_codex_app_server_message_oversize_error(bytes.len()) {
return Err(error);
}
let mut writer = self.inner.writer.lock().await;
writer
.write_all(&bytes)
@@ -2616,13 +2642,18 @@ impl CodexAppServerConnection {
return Err(platform_llm::LlmError::InvalidRequest(error));
}
};
if let Err(error) = self
.request(
"thread/inject_items",
serde_json::json!({"threadId": thread_id, "items": items}),
)
.await
let params = serde_json::json!({"threadId": thread_id.clone(), "items": items});
let payload_bytes = serde_json::to_vec(&params)
.map(|bytes| bytes.len().saturating_add(1))
.unwrap_or(usize::MAX);
// 注入前的前置校验:失败关闭并指名 itemId 与字节数,**不截断、不摘要、不改写**。
if let Some(error) =
direct_project_history_injection_oversize_error(&params, payload_bytes)
{
self.release_thread(snapshot, &thread_id).await;
return Err(platform_llm::LlmError::InvalidRequest(error));
}
if let Err(error) = self.request("thread/inject_items", params).await {
self.release_thread(snapshot, &thread_id).await;
return Err(platform_llm::LlmError::Transport(error));
}
@@ -3429,6 +3460,61 @@ async fn read_game_creator_codex_app_server_stdout(
}
}
/// 写侧守卫的纯函数部分(便于单测):超限时给出带字节数的错误。
///
/// 与读侧共用同一个上限,保证「我们能注入的」一定「我们读得回来」。
fn game_creator_codex_app_server_message_oversize_error(byte_len: usize) -> Option<String> {
let limit = GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES;
(byte_len > limit).then(|| {
format!(
"Codex app-server JSON-RPC 单行超过大小上限(写侧):{byte_len} 字节 > 上限 {limit} 字节;未发送、未截断"
)
})
}
/// DirectProject 历史注入的前置校验(纯函数,便于单测)。
///
/// 判据来自实测:app-server 会把注入的 item **逐条原样回显**,所以
/// ① 单条 item 必须 ≤ 上限 − 回显信封余量;② 整份载荷本身也必须 ≤ 上限。
/// 任一不满足即**失败关闭**(指名 `itemId` 与字节数),**不截断、不摘要、不改写**历史。
fn direct_project_history_injection_oversize_error(
params: &serde_json::Value,
payload_bytes: usize,
) -> Option<String> {
let limit = GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES;
if payload_bytes > limit {
return Some(format!(
"{DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX}:整份历史载荷 {payload_bytes} 字节 > 上限 {limit} 字节;未注入、未截断"
));
}
let Some(items) = params.get("items").and_then(serde_json::Value::as_array) else {
return None;
};
let item_limit = limit.saturating_sub(GAME_CREATOR_CODEX_APP_SERVER_ECHO_ENVELOPE_MARGIN_BYTES);
for item in items {
let Ok(bytes) = serde_json::to_vec(item) else {
return Some(format!(
"{DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX}:历史记录无法序列化;未注入、未截断"
));
};
if bytes.len() > item_limit {
let item_id = item
.get("id")
.and_then(serde_json::Value::as_str)
.unwrap_or("<无 id>");
let item_type = item
.get("type")
.and_then(serde_json::Value::as_str)
.unwrap_or("<无 type>");
return Some(format!(
"{DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX}:单条记录 {} 字节 > 单条上限 {item_limit} 字节(itemId={item_id}, type={item_type});未注入、未截断",
bytes.len()
));
}
}
None
}
async fn read_bounded_game_creator_codex_app_server_line<R>(
reader: &mut R,
) -> Result<Option<Vec<u8>>, String>
@@ -5437,6 +5523,66 @@ while IFS= read -r line; do :; done
assert!(!diagnostic.contains(std::str::from_utf8(&secret).expect("fixture utf8")));
}
/// 写侧对称守卫:我们绝不写出自己读不回来的行。
///
/// 与上面那条读侧用例共用 `..._LINE_MAX_BYTES`,所以这条断言同时锁住「两侧同界」这个不变量:
/// 上限本身必须放行,上限 + 1 必须失败关闭(而不是被静默截断)。
#[test]
fn codex_app_server_write_guard_matches_the_read_cap() {
assert!(game_creator_codex_app_server_message_oversize_error(
GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES
)
.is_none());
let oversized = GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES + 1;
let error = game_creator_codex_app_server_message_oversize_error(oversized)
.expect("oversized outbound message must be rejected before it is written");
assert!(error.contains("单行超过大小上限(写侧)"));
assert!(error.contains(&oversized.to_string()));
assert!(error.contains("未发送、未截断"));
}
/// DirectProject 历史注入的前置校验:**单条 item** 与**整份载荷**都必须失败关闭。
///
/// 形状照抄实测(隔离探针,codex-cli 0.147.0):`thread/inject_items` 注入的 item 会被
/// app-server 逐条原样回显成一条 `rawResponseItem/completed`,所以「单条 item 超限」就等于
/// 「回显行超限」——必须在注入前拦下,而不是让它在读行时变成连接级故障。
#[test]
fn direct_project_history_injection_rejects_oversized_items_and_payloads() {
let ok_params = serde_json::json!({
"threadId": "probe-thread",
"items": [{ "type": "message", "id": "msg_1", "role": "user" }],
});
assert!(direct_project_history_injection_oversize_error(&ok_params, 1024).is_none());
let payload_error = direct_project_history_injection_oversize_error(
&ok_params,
GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES + 1,
)
.expect("oversized payload must be rejected");
assert!(payload_error.contains(DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX));
assert!(payload_error.contains("整份历史载荷"));
assert!(payload_error.contains("未注入、未截断"));
let huge = "x".repeat(GAME_CREATOR_CODEX_APP_SERVER_LINE_MAX_BYTES);
let big_params = serde_json::json!({
"threadId": "probe-thread",
"items": [{
"type": "message",
"id": "msg_big",
"role": "assistant",
"content": [{ "type": "output_text", "text": huge }],
}],
});
let item_error = direct_project_history_injection_oversize_error(&big_params, 1024)
.expect("oversized single item must be rejected");
assert!(item_error.contains(DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_PREFIX));
assert!(item_error.contains("单条记录"));
assert!(item_error.contains("itemId=msg_big"));
assert!(item_error.contains("type=message"));
assert!(item_error.contains("未注入、未截断"));
}
#[cfg(unix)]
#[tokio::test]
async fn codex_app_server_uses_initialize_thread_and_turn_protocol() {
@@ -1786,6 +1786,9 @@ fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &st
{
return "历史画布资源不满足安全恢复条件,请先在资源画布确认唯一可用的核心图集";
}
if direct_project_history_injection_oversize(error) {
return "项目对话历史有单条记录或整份载荷超过注入上限,无法整体注入 Codex;请按项目诊断里的 itemId 处理该条记录后再发送需求";
}
if direct_project_history_shape_failure(error) {
return "项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求";
}
@@ -1809,6 +1812,9 @@ fn direct_codex_failure_public_summary(error: &str) -> Option<&'static str> {
if direct_codex_error_is_mud_points_insufficient(error) {
return Some("泥点余额不足");
}
if direct_project_history_injection_oversize(error) {
return Some("项目对话历史有单条记录超过注入上限");
}
if private_external_editor_credentials_storage_preparation_failed(error) {
return Some("本机开发者凭据存储目录未安全初始化;未创建远端凭据");
}
@@ -1825,6 +1831,11 @@ fn direct_codex_failure_is_retryable(error: &str) -> bool {
if direct_project_history_shape_failure(error) {
return false;
}
// 注入超限与「行形状」同类:同一份历史文件每次读都会得到同一结论,重试只会
// 再次注入同一份(且本轮用户消息已先追加进同一文件,载荷只会更大),因此不标可重试。
if direct_project_history_injection_oversize(error) {
return false;
}
![
"private-external-editor-credential-storage-preparation-failed",
"private-external-editor-credential-persistence-failed",
@@ -1846,6 +1857,20 @@ const DIRECT_PROJECT_HISTORY_SHAPE_FAILURE_MARKERS: &[&str] = &[
"解析 DirectProject 历史失败",
];
/// DirectProject 历史**注入超限**的失败标记。
///
/// 判据是「同一份历史 ⇒ 同一份载荷 ⇒ 同一结论」:本次注入因为单条记录(或整份载荷)超过
/// 单行上限而被前置校验拦下(前缀与字节数定义在 `agent/codex_app_server.rs`),重试只会再注入
/// 同一份、且更大的历史。所以按不可重试处理,并给专属恢复提示;这里沿用本文件既有的
/// 「字面量子串」口径,只取前缀的特征子串。
const DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_MARKERS: &[&str] = &["历史注入载荷超过单行上限"];
fn direct_project_history_injection_oversize(error: &str) -> bool {
DIRECT_PROJECT_HISTORY_INJECTION_OVERSIZE_MARKERS
.iter()
.any(|marker| error.contains(marker))
}
fn direct_project_history_shape_failure(error: &str) -> bool {
DIRECT_PROJECT_HISTORY_SHAPE_FAILURE_MARKERS
.iter()