diff --git a/backend/crates/providers/openai/src/transport/correlation.rs b/backend/crates/providers/openai/src/transport/correlation.rs
index 97700c2fd..91612e802 100644
--- a/backend/crates/providers/openai/src/transport/correlation.rs
+++ b/backend/crates/providers/openai/src/transport/correlation.rs
@@ -205,6 +205,16 @@ pub(super) fn scope_request(
let Some(installation_id) = installation_id else {
return;
};
+ // 输入项的轮次引用与请求 metadata 共用账号域,保留 item/call ID 和业务元数据
+ if let Some(Value::Array(input)) = body.get_mut("input") {
+ for item in input {
+ if let Some(Value::Object(metadata)) =
+ item.get_mut("internal_chat_message_metadata_passthrough")
+ {
+ map_fields(metadata, installation_id, &["turn_id"]);
+ }
+ }
+ }
// 只处理官方自动 Git 上下文的关联字段,不扫描普通用户输入和工具输出
let Ok(targets) = locate_contexts(body, true, || Ok(())) else {
return;
diff --git a/backend/crates/providers/openai/tests/provider/contract/correlation.rs b/backend/crates/providers/openai/tests/provider/contract/correlation.rs
index 3e3da3d4c..652654304 100644
--- a/backend/crates/providers/openai/tests/provider/contract/correlation.rs
+++ b/backend/crates/providers/openai/tests/provider/contract/correlation.rs
@@ -16,11 +16,25 @@ fn payload(websocket: bool) -> ProtocolPayload {
.as_array()
.unwrap()
.clone();
+ for (index, item) in input.iter_mut().enumerate() {
+ let metadata = item
+ .as_object_mut()
+ .unwrap()
+ .entry("internal_chat_message_metadata_passthrough")
+ .or_insert_with(|| json!({}));
+ metadata["turn_id"] = json!(if index == 3 {
+ "source-turn"
+ } else {
+ "source-previous-turn"
+ });
+ }
input.insert(0, json!({"role":"user", "content":[{"type":"input_text", "text":concat!(
"",
r#"{"sessionId":"source-session","threadId":"source-thread","clientUserMessageId":"source-turn","path":"/workspace/project"}"#,
""
- )}], "internal_chat_message_metadata_passthrough":{"content_item_kinds":["additional_content.codex_git_state"]}}));
+ )}], "internal_chat_message_metadata_passthrough":{
+ "turn_id":"source-turn", "content_item_kinds":["additional_content.codex_git_state"]
+ }}));
ProtocolPayload::json_object("openai", json!({
"model":"gpt-5.4", "input":input,
"prompt_cache_key":"custom-cache-key", "client_metadata":{
@@ -143,7 +157,32 @@ async fn account_correlation_is_stable_across_transports_and_changes_only_with_a
assert_eq!(full["tool_namespaces_info"]["inventory"], "body-only");
assert_eq!(full["future"], metadata()["future"]);
let input = body["input"].as_array().unwrap();
- assert_eq!(json!(&input[1..]), account_isolation::isolation_history());
+ assert_eq!(
+ input[0]["internal_chat_message_metadata_passthrough"],
+ json!({"turn_id":flat["turn_id"], "content_item_kinds":["additional_content.codex_git_state"]}),
+ );
+ let previous_turn = &input[1]["internal_chat_message_metadata_passthrough"]["turn_id"];
+ assert_scoped_id(previous_turn.as_str().unwrap(), "source-previous-turn");
+ assert_ne!(previous_turn, &flat["turn_id"]);
+ let mut expected_history = account_isolation::isolation_history();
+ for (index, item) in expected_history
+ .as_array_mut()
+ .unwrap()
+ .iter_mut()
+ .enumerate()
+ {
+ let metadata = item
+ .as_object_mut()
+ .unwrap()
+ .entry("internal_chat_message_metadata_passthrough")
+ .or_insert_with(|| json!({}));
+ metadata["turn_id"] = if index == 3 {
+ flat["turn_id"].clone()
+ } else {
+ previous_turn.clone()
+ };
+ }
+ assert_eq!(json!(&input[1..]), expected_history);
let document =
roxmltree::Document::parse(input[0]["content"][0]["text"].as_str().unwrap())
.unwrap();
@@ -168,7 +207,15 @@ async fn account_correlation_is_stable_across_transports_and_changes_only_with_a
body["prompt_cache_key"].as_str().unwrap(),
"custom-cache-key",
);
- captures.push((flat["session_id"].clone(), body["prompt_cache_key"].clone()));
+ captures.push((
+ [
+ flat["session_id"].clone(),
+ flat["thread_id"].clone(),
+ flat["turn_id"].clone(),
+ ],
+ body["prompt_cache_key"].clone(),
+ previous_turn.clone(),
+ ));
}
}
for index in [1, 2, 3, 6, 7] {
@@ -177,6 +224,7 @@ async fn account_correlation_is_stable_across_transports_and_changes_only_with_a
assert_eq!(captures[4], captures[5]);
assert_ne!(captures[0].0, captures[4].0);
assert_ne!(captures[0].1, captures[4].1);
+ assert_ne!(captures[0].2, captures[4].2);
}
#[tokio::test]
diff --git a/docs/api.md b/docs/api.md
index 1b5711dac..7c26d41cd 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -299,6 +299,8 @@ Responses 也不透传 `x-stainless-*`、`Origin`、`Referer`、`sec-ch-ua*` 和
会话、线程、轮次、窗口及父子引用在出站时按账号安装身份稳定映射为 UUID;
同账号保留引用关系,换账号使用不同标识。HTTP 请求头、正文 metadata、WS 副本及
已识别的 Desktop 自动 Git 上下文使用一致映射。
+输入项的 `internal_chat_message_metadata_passthrough.turn_id` 同步映射,
+保留历史轮次与当前轮次的引用关系,其余输入项元数据保持不变。
Images 的 `x-codex-image-turn-id`、turn metadata 和 Search 的会话 `id` 使用同一账号域;
Search 只替换该 `id` 的原始字节,其余正文保持不变。
`prompt_cache_key` 同样按账号映射,保留同账号内的缓存共享关系;未提供时不补造。
@@ -1571,9 +1573,13 @@ JSON 路径支持 `$`、`.成员`、`["带特殊字符的键"]`、`[索引]`、`
`$.workspaces.*.associated_remote_urls.*` 配合 `remove_field` 和 `pattern: "private-org"` 只删除命中的远端;
`$.workspaces` 配合 `rename_key` 可改写工作区路径键名。环境文本替换必须保留 XML 包裹结构
-管理端提供「统一工作区信息」预设,生成一条含多个通用步骤的规则。
-预设统一路径中的用户名为 `user`,工作区路径键和环境工作目录保留实际目录名并改为 `/workspace/<项目名>`,
-保留实际仓库、提交、分支、工作区标识和会话关联信息。安装身份由账号隔离逻辑处理,
+管理端提供「统一工作区信息」预设,生成一条含多个步骤的规则,仅处理 turn metadata 的 `workspaces`:
+
+- `/home/<用户名>`、`/Users/<用户名>` 及 Windows 用户目录中的用户名改为 `user`,保留其余目录结构和项目名
+- Git 远端 URL 的账号或组织路径段改为 `alex`,保留协议、主机和仓库名,支持 HTTP(S)、SSH、Git 协议和 `git@host:owner/repo` 形式;多级命名空间及本地远端路径保持原值
+
+提交、分支、工作区标识和会话关联信息保持原值。正文中的工作目录及其他执行路径也保持原值,
+供客户端工具使用,仍可能包含真实用户名和项目名。安装身份由账号隔离逻辑处理,
UA、平台参数及自动 Git 上下文版本由 [OpenAI 上游客户端身份](#openai-上游客户端身份)管理。
预设和自定义规则使用同一编辑器,步骤均可增删、修改和排序;调整预设不覆盖已保存的规则
diff --git a/frontend/src/views/settings/components/privacy/presets.ts b/frontend/src/views/settings/components/privacy/presets.ts
index 8a55e545d..62e84afce 100644
--- a/frontend/src/views/settings/components/privacy/presets.ts
+++ b/frontend/src/views/settings/components/privacy/presets.ts
@@ -43,11 +43,9 @@ export const presets: { key: string, name: string, steps: Partial[]
key: 'workspace',
name: '统一工作区信息',
steps: [
+ // 只改写附加元数据中的路径,正文路径供本地工具执行使用
{ scope: 'turn_metadata', selector: '$.workspaces', action: 'rename_key', pattern: String.raw`(?i)^((?:[a-z]:)?[/\\](?:home|users)[/\\])[^/\\]+`, replacement: `\${1}user` },
- { scope: 'environment_text', selector: '$', action: 'regex_replace', pattern: String.raw`(?i)((?:[a-z]:)?[/\\](?:home|users)[/\\])[^/\\<>"'\r\n]+`, replacement: `\${1}user` },
- // 路径隐藏本机目录布局,项目名和 Git 状态仍取自实际请求
- { scope: 'turn_metadata', selector: '$.workspaces', action: 'rename_key', pattern: String.raw`(?s)^(?:[a-zA-Z]:)?[/\\](?:.*[/\\])?([^/\\]+)[/\\]*$`, replacement: `/workspace/\${1}` },
- { scope: 'environment_text', selector: '$', action: 'regex_replace', pattern: String.raw`(\s*)(?:[a-zA-Z]:)?[/\\](?:[^<>\r\n]*[/\\])?([^/\\<>\r\n]+?)[/\\]*\s*()`, replacement: `\${1}/workspace/\${2}\${3}` },
+ { scope: 'turn_metadata', selector: '$.workspaces.*.associated_remote_urls.*', action: 'regex_replace', pattern: String.raw`(?i)^((?:(?:https?|ssh|git)://[^/?#]+/|[^/@:\s]+@[^/:\s]+:))[^/?#]+/([^/?#]+)(/?)$`, replacement: `\${1}alex/\${2}\${3}` },
],
},
]