Merge branch 'main' of github.com:openai/codex into dev/mzeng/codex_apps_mcp_declare

# Conflicts:
#	codex-rs/core/src/mcp_tool_call.rs
This commit is contained in:
Matthew Zeng
2026-05-04 10:33:11 -07:00
142 changed files with 19508 additions and 15554 deletions
+11 -2
View File
@@ -6,8 +6,13 @@ set -euo pipefail
# invocation so target-discovery queries can reuse the same Bazel server.
query_args=()
windows_cross_compile=0
while [[ $# -gt 0 ]]; do
case "$1" in
--windows-cross-compile)
windows_cross_compile=1
shift
;;
--)
shift
break
@@ -20,7 +25,7 @@ while [[ $# -gt 0 ]]; do
done
if [[ $# -ne 1 ]]; then
echo "Usage: $0 [<bazel query args>...] -- <query expression>" >&2
echo "Usage: $0 [--windows-cross-compile] [<bazel query args>...] -- <query expression>" >&2
exit 1
fi
@@ -32,7 +37,11 @@ case "${RUNNER_OS:-}" in
ci_config=ci-macos
;;
Windows)
ci_config=ci-windows
if [[ $windows_cross_compile -eq 1 ]]; then
ci_config=ci-windows-cross
else
ci_config=ci-windows
fi
;;
esac
+21 -9
View File
@@ -251,17 +251,24 @@ jobs:
--build_metadata=TAG_job=clippy
)
bazel_wrapper_args=()
bazel_target_list_args=()
if [[ "${RUNNER_OS}" == "Windows" ]]; then
# Keep this aligned with the Windows Bazel test job. With the
# default `//:local_windows` host platform, Windows `rust_test`
# targets such as `//codex-rs/core:core-all-test` can be skipped
# by `--skip_incompatible_explicit_targets`, which hides clippy
# diagnostics from integration-test modules.
bazel_wrapper_args+=(--windows-msvc-host-platform)
bazel_clippy_args+=(--skip_incompatible_explicit_targets)
# Keep this aligned with the fast Windows Bazel test job: use
# Linux RBE for clippy build actions while targeting Windows
# gnullvm. Fork/community PRs without the BuildBuddy secret fall
# back inside `run-bazel-ci.sh` to the previous local Windows MSVC
# host-platform shape.
bazel_wrapper_args+=(--windows-cross-compile)
bazel_target_list_args+=(--windows-cross-compile)
if [[ -z "${BUILDBUDDY_API_KEY:-}" ]]; then
# The fork fallback can see incompatible explicit Windows-cross
# internal test binaries in the generated target list. Preserve
# the old local-fallback behavior there.
bazel_clippy_args+=(--skip_incompatible_explicit_targets)
fi
fi
bazel_target_lines="$(./scripts/list-bazel-clippy-targets.sh)"
bazel_target_lines="$(./scripts/list-bazel-clippy-targets.sh "${bazel_target_list_args[@]}")"
bazel_targets=()
while IFS= read -r target; do
bazel_targets+=("${target}")
@@ -333,7 +340,12 @@ jobs:
# Rust debug assertions explicitly.
bazel_wrapper_args=()
if [[ "${RUNNER_OS}" == "Windows" ]]; then
bazel_wrapper_args+=(--windows-msvc-host-platform)
# This is build-only signal, so use the same Linux-RBE
# cross-compile path as the fast Windows test and clippy jobs.
# Fork/community PRs without the BuildBuddy secret fall back
# inside `run-bazel-ci.sh` to the previous local Windows MSVC
# host-platform shape.
bazel_wrapper_args+=(--windows-cross-compile)
fi
bazel_build_args=(
+10 -2
View File
@@ -44,7 +44,7 @@ jobs:
6. iOS — Issues with the Codex iOS app.
- Additionally add zero or more of the following labels that are relevant to the issue content. Prefer a small set of precise labels over many broad ones.
- For agent-area issues, prefer the most specific applicable label. Use "agent" only as a fallback for agent-related issues that do not fit a more specific agent-area label. Prefer "app-server" over "session" or "config" when the issue is about app-server protocol, API, RPC, schema, launch, or bridge behavior.
- For agent-area issues, prefer the most specific applicable label. Use "agent" only as a fallback for agent-related issues that do not fit a more specific agent-area label. Prefer "app-server" over "session" or "config" when the issue is about app-server protocol, API, RPC, schema, launch, or bridge behavior. Use "memory" for agentic memory storage/retrieval and "performance" for high process memory utilization or memory leaks.
1. windows-os — Bugs or friction specific to Windows environments (always when PowerShell is mentioned, path handling, copy/paste, OS-specific auth or tooling failures).
2. mcp — Topics involving Model Context Protocol servers/clients.
3. mcp-server — Problems related to the codex mcp-server command, where codex runs as an MCP server.
@@ -68,7 +68,15 @@ jobs:
21. session - Issues involving session or thread management, including resume, fork, archive, rename/title, thread history, rollout persistence, compaction, checkpoints, retention, and cross-session state.
22. config - Issues involving config.toml, config keys, config key merging, config updates, profiles, hooks config, project config, agent role TOMLs, instruction/personality config, and config schema behavior.
23. plan - Issues involving plan mode, planning workflows, or plan-specific tools/behavior.
24. agent - Fallback only for core agent loop or agent-related issues that do not fit app-server, connectivity, subagent, session, config, or plan.
24. computer-use - Issues involving agentic computer use or SkyComputerUseService.
25. browser - Issues involving agentic browser use, IAB, or the built-in browser within the Codex app.
26. memory - Issues involving agentic memory storage and retrieval.
27. imagen - Issues involving image generation.
28. remote - Issues involving remote access, remote control, or SSH.
29. performance - Issues involving slow, laggy performance, high memory utilization, or memory leaks.
30. automations - Issues involving scheduled automation tasks or heartbeats.
31. pets - Issues involving pets avatars and animations.
32. agent - Fallback only for core agent loop or agent-related issues that do not fit app-server, connectivity, subagent, session, config, plan, computer-use, browser, memory, imagen, remote, performance, automations, or pets.
Issue number: ${{ github.event.issue.number }}
+18
View File
@@ -2220,6 +2220,7 @@ dependencies = [
"opentelemetry_sdk",
"pretty_assertions",
"rand 0.9.3",
"rcgen",
"reqwest",
"rustls",
"rustls-native-certs",
@@ -3037,6 +3038,23 @@ dependencies = [
"wiremock",
]
[[package]]
name = "codex-memories-mcp"
version = "0.0.0"
dependencies = [
"anyhow",
"codex-utils-absolute-path",
"codex-utils-output-truncation",
"pretty_assertions",
"rmcp",
"schemars 0.8.22",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tokio",
]
[[package]]
name = "codex-memories-read"
version = "0.0.0"
+7
View File
@@ -52,6 +52,7 @@ members = [
"login",
"codex-mcp",
"mcp-server",
"memories/mcp",
"memories/read",
"memories/write",
"model-provider-info",
@@ -168,6 +169,7 @@ codex-keyring-store = { path = "keyring-store" }
codex-linux-sandbox = { path = "linux-sandbox" }
codex-lmstudio = { path = "lmstudio" }
codex-login = { path = "login" }
codex-memories-mcp = { path = "memories/mcp" }
codex-memories-read = { path = "memories/read" }
codex-memories-write = { path = "memories/write" }
codex-mcp = { path = "codex-mcp" }
@@ -322,6 +324,10 @@ quick-xml = "0.38.4"
rand = "0.9"
ratatui = "0.29.0"
ratatui-macros = "0.6.0"
rcgen = { version = "0.14.7", default-features = false, features = [
"aws_lc_rs",
"pem",
] }
regex = "1.12.3"
regex-lite = "0.1.8"
reqwest = { version = "0.12", features = ["cookies"] }
@@ -457,6 +463,7 @@ unwrap_used = "deny"
[workspace.metadata.cargo-shear]
ignored = [
"codex-agent-graph-store",
"codex-memories-mcp",
"icu_provider",
"openssl-sys",
"codex-utils-readiness",
@@ -2850,6 +2850,28 @@
"title": "CompactionResponseItem",
"type": "object"
},
{
"properties": {
"encrypted_content": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"context_compaction"
],
"title": "ContextCompactionResponseItemType",
"type": "string"
}
},
"required": [
"type"
],
"title": "ContextCompactionResponseItem",
"type": "object"
},
{
"properties": {
"type": {
@@ -13791,6 +13791,28 @@
"title": "CompactionResponseItem",
"type": "object"
},
{
"properties": {
"encrypted_content": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"context_compaction"
],
"title": "ContextCompactionResponseItemType",
"type": "string"
}
},
"required": [
"type"
],
"title": "ContextCompactionResponseItem",
"type": "object"
},
{
"properties": {
"type": {
@@ -10444,6 +10444,28 @@
"title": "CompactionResponseItem",
"type": "object"
},
{
"properties": {
"encrypted_content": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"context_compaction"
],
"title": "ContextCompactionResponseItemType",
"type": "string"
}
},
"required": [
"type"
],
"title": "ContextCompactionResponseItem",
"type": "object"
},
{
"properties": {
"type": {
@@ -732,6 +732,28 @@
"title": "CompactionResponseItem",
"type": "object"
},
{
"properties": {
"encrypted_content": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"context_compaction"
],
"title": "ContextCompactionResponseItemType",
"type": "string"
}
},
"required": [
"type"
],
"title": "ContextCompactionResponseItem",
"type": "object"
},
{
"properties": {
"type": {
@@ -862,6 +862,28 @@
"title": "CompactionResponseItem",
"type": "object"
},
{
"properties": {
"encrypted_content": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"context_compaction"
],
"title": "ContextCompactionResponseItemType",
"type": "string"
}
},
"required": [
"type"
],
"title": "ContextCompactionResponseItem",
"type": "object"
},
{
"properties": {
"type": {
@@ -14,4 +14,4 @@ export type ResponseItem = { "type": "message", role: string, content: Array<Con
/**
* Set when using the Responses API.
*/
call_id: string | null, status: LocalShellStatus, action: LocalShellAction, } | { "type": "function_call", name: string, namespace?: string, arguments: string, call_id: string, } | { "type": "tool_search_call", call_id: string | null, status?: string, execution: string, arguments: unknown, } | { "type": "function_call_output", call_id: string, output: FunctionCallOutputBody, } | { "type": "custom_tool_call", status?: string, call_id: string, name: string, input: string, } | { "type": "custom_tool_call_output", call_id: string, name?: string, output: FunctionCallOutputBody, } | { "type": "tool_search_output", call_id: string | null, status: string, execution: string, tools: unknown[], } | { "type": "web_search_call", status?: string, action?: WebSearchAction, } | { "type": "image_generation_call", id: string, status: string, revised_prompt?: string, result: string, } | { "type": "compaction", encrypted_content: string, } | { "type": "other" };
call_id: string | null, status: LocalShellStatus, action: LocalShellAction, } | { "type": "function_call", name: string, namespace?: string, arguments: string, call_id: string, } | { "type": "tool_search_call", call_id: string | null, status?: string, execution: string, arguments: unknown, } | { "type": "function_call_output", call_id: string, output: FunctionCallOutputBody, } | { "type": "custom_tool_call", status?: string, call_id: string, name: string, input: string, } | { "type": "custom_tool_call_output", call_id: string, name?: string, output: FunctionCallOutputBody, } | { "type": "tool_search_output", call_id: string | null, status: string, execution: string, tools: unknown[], } | { "type": "web_search_call", status?: string, action?: WebSearchAction, } | { "type": "image_generation_call", id: string, status: string, revised_prompt?: string, result: string, } | { "type": "compaction", encrypted_content: string, } | { "type": "context_compaction", encrypted_content?: string, } | { "type": "other" };
@@ -12,9 +12,6 @@ use crate::protocol::v2::DynamicToolCallStatus;
use crate::protocol::v2::FileChangePatchUpdatedNotification;
use crate::protocol::v2::ItemCompletedNotification;
use crate::protocol::v2::ItemStartedNotification;
use crate::protocol::v2::McpToolCallError;
use crate::protocol::v2::McpToolCallResult;
use crate::protocol::v2::McpToolCallStatus;
use crate::protocol::v2::PlanDeltaNotification;
use crate::protocol::v2::ReasoningSummaryPartAddedNotification;
use crate::protocol::v2::ReasoningSummaryTextDeltaNotification;
@@ -23,7 +20,6 @@ use crate::protocol::v2::TerminalInteractionNotification;
use crate::protocol::v2::ThreadItem;
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem;
use codex_protocol::protocol::EventMsg;
use serde_json::Value as JsonValue;
use std::collections::HashMap;
/// Build the v2 app-server notification that directly corresponds to a single core event.
@@ -75,64 +71,6 @@ pub fn item_event_to_server_notification(
item,
})
}
EventMsg::McpToolCallBegin(begin_event) => {
let item = ThreadItem::McpToolCall {
id: begin_event.call_id,
server: begin_event.invocation.server,
tool: begin_event.invocation.tool,
status: McpToolCallStatus::InProgress,
arguments: begin_event.invocation.arguments.unwrap_or(JsonValue::Null),
mcp_app_resource_uri: begin_event.mcp_app_resource_uri,
result: None,
error: None,
duration_ms: None,
};
ServerNotification::ItemStarted(ItemStartedNotification {
thread_id,
turn_id,
item,
})
}
EventMsg::McpToolCallEnd(end_event) => {
let status = if end_event.is_success() {
McpToolCallStatus::Completed
} else {
McpToolCallStatus::Failed
};
let duration_ms = i64::try_from(end_event.duration.as_millis()).ok();
let (result, error) = match &end_event.result {
Ok(value) => (
Some(Box::new(McpToolCallResult {
content: value.content.clone(),
structured_content: value.structured_content.clone(),
meta: value.meta.clone(),
})),
None,
),
Err(message) => (
None,
Some(McpToolCallError {
message: message.clone(),
}),
),
};
let item = ThreadItem::McpToolCall {
id: end_event.call_id,
server: end_event.invocation.server,
tool: end_event.invocation.tool,
status,
arguments: end_event.invocation.arguments.unwrap_or(JsonValue::Null),
mcp_app_resource_uri: end_event.mcp_app_resource_uri,
result,
error,
duration_ms,
};
ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id,
turn_id,
item,
})
}
EventMsg::CollabAgentSpawnBegin(begin_event) => {
let item = ThreadItem::CollabAgentToolCall {
id: begin_event.call_id,
@@ -500,17 +438,11 @@ pub fn item_event_to_server_notification(
mod tests {
use super::*;
use codex_protocol::ThreadId;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::protocol::CollabResumeBeginEvent;
use codex_protocol::protocol::CollabResumeEndEvent;
use codex_protocol::protocol::ExecCommandOutputDeltaEvent;
use codex_protocol::protocol::ExecOutputStream;
use codex_protocol::protocol::McpInvocation;
use codex_protocol::protocol::McpToolCallBeginEvent;
use codex_protocol::protocol::McpToolCallEndEvent;
use pretty_assertions::assert_eq;
use rmcp::model::Content;
use std::time::Duration;
fn assert_item_started_server_notification(
notification: ServerNotification,
@@ -621,179 +553,6 @@ mod tests {
);
}
#[test]
fn mcp_tool_call_begin_maps_to_item_started_notification_with_args() {
let begin_event = McpToolCallBeginEvent {
call_id: "call_123".to_string(),
invocation: McpInvocation {
server: "codex".to_string(),
tool: "list_mcp_resources".to_string(),
arguments: Some(serde_json::json!({"server": ""})),
},
mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()),
};
let notification = item_event_to_server_notification(
EventMsg::McpToolCallBegin(begin_event.clone()),
"thread-1",
"turn_1",
);
assert_item_started_server_notification(
notification,
ItemStartedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn_1".to_string(),
item: ThreadItem::McpToolCall {
id: begin_event.call_id,
server: begin_event.invocation.server,
tool: begin_event.invocation.tool,
status: McpToolCallStatus::InProgress,
arguments: serde_json::json!({"server": ""}),
mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()),
result: None,
error: None,
duration_ms: None,
},
},
);
}
#[test]
fn mcp_tool_call_begin_maps_to_item_started_notification_without_args() {
let begin_event = McpToolCallBeginEvent {
call_id: "call_456".to_string(),
invocation: McpInvocation {
server: "codex".to_string(),
tool: "list_mcp_resources".to_string(),
arguments: None,
},
mcp_app_resource_uri: None,
};
let notification = item_event_to_server_notification(
EventMsg::McpToolCallBegin(begin_event.clone()),
"thread-2",
"turn_2",
);
assert_item_started_server_notification(
notification,
ItemStartedNotification {
thread_id: "thread-2".to_string(),
turn_id: "turn_2".to_string(),
item: ThreadItem::McpToolCall {
id: begin_event.call_id,
server: begin_event.invocation.server,
tool: begin_event.invocation.tool,
status: McpToolCallStatus::InProgress,
arguments: JsonValue::Null,
mcp_app_resource_uri: None,
result: None,
error: None,
duration_ms: None,
},
},
);
}
#[test]
fn mcp_tool_call_end_maps_to_item_completed_notification_on_success() {
let content = vec![
serde_json::to_value(Content::text("{\"resources\":[]}"))
.expect("content should serialize"),
];
let result = CallToolResult {
content: content.clone(),
is_error: Some(false),
structured_content: None,
meta: Some(serde_json::json!({
"ui/resourceUri": "ui://widget/list-resources.html"
})),
};
let end_event = McpToolCallEndEvent {
call_id: "call_789".to_string(),
invocation: McpInvocation {
server: "codex".to_string(),
tool: "list_mcp_resources".to_string(),
arguments: Some(serde_json::json!({"server": ""})),
},
mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()),
duration: Duration::from_nanos(92708),
result: Ok(result),
};
let notification = item_event_to_server_notification(
EventMsg::McpToolCallEnd(end_event.clone()),
"thread-3",
"turn_3",
);
assert_item_completed_server_notification(
notification,
ItemCompletedNotification {
thread_id: "thread-3".to_string(),
turn_id: "turn_3".to_string(),
item: ThreadItem::McpToolCall {
id: end_event.call_id,
server: end_event.invocation.server,
tool: end_event.invocation.tool,
status: McpToolCallStatus::Completed,
arguments: serde_json::json!({"server": ""}),
mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()),
result: Some(Box::new(McpToolCallResult {
content,
structured_content: None,
meta: Some(serde_json::json!({
"ui/resourceUri": "ui://widget/list-resources.html"
})),
})),
error: None,
duration_ms: Some(0),
},
},
);
}
#[test]
fn mcp_tool_call_end_maps_to_item_completed_notification_on_error() {
let end_event = McpToolCallEndEvent {
call_id: "call_err".to_string(),
invocation: McpInvocation {
server: "codex".to_string(),
tool: "list_mcp_resources".to_string(),
arguments: None,
},
mcp_app_resource_uri: None,
duration: Duration::from_millis(1),
result: Err("boom".to_string()),
};
let notification = item_event_to_server_notification(
EventMsg::McpToolCallEnd(end_event.clone()),
"thread-4",
"turn_4",
);
assert_item_completed_server_notification(
notification,
ItemCompletedNotification {
thread_id: "thread-4".to_string(),
turn_id: "turn_4".to_string(),
item: ThreadItem::McpToolCall {
id: end_event.call_id,
server: end_event.invocation.server,
tool: end_event.invocation.tool,
status: McpToolCallStatus::Failed,
arguments: JsonValue::Null,
mcp_app_resource_uri: None,
result: None,
error: Some(McpToolCallError {
message: "boom".to_string(),
}),
duration_ms: Some(1),
},
},
);
}
#[test]
fn exec_command_output_delta_maps_to_command_execution_output_delta() {
let notification = item_event_to_server_notification(
@@ -359,6 +359,7 @@ impl ThreadHistoryBuilder {
| codex_protocol::items::TurnItem::ImageView(_)
| codex_protocol::items::TurnItem::ImageGeneration(_)
| codex_protocol::items::TurnItem::FileChange(_)
| codex_protocol::items::TurnItem::McpToolCall(_)
| codex_protocol::items::TurnItem::ContextCompaction(_) => {}
}
}
@@ -382,6 +383,7 @@ impl ThreadHistoryBuilder {
| codex_protocol::items::TurnItem::ImageView(_)
| codex_protocol::items::TurnItem::ImageGeneration(_)
| codex_protocol::items::TurnItem::FileChange(_)
| codex_protocol::items::TurnItem::McpToolCall(_)
| codex_protocol::items::TurnItem::ContextCompaction(_) => {}
}
}
@@ -31,6 +31,8 @@ use codex_protocol::config_types::Verbosity;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::config_types::WebSearchToolConfig;
use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent;
use codex_protocol::items::McpToolCallError as CoreMcpToolCallError;
use codex_protocol::items::McpToolCallStatus as CoreMcpToolCallStatus;
use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::mcp::CallToolResult as CoreMcpCallToolResult;
use codex_protocol::mcp::Resource as McpResource;
@@ -2783,6 +2785,24 @@ impl From<CoreMcpCallToolResult> for McpServerToolCallResponse {
}
}
impl From<CoreMcpCallToolResult> for McpToolCallResult {
fn from(result: CoreMcpCallToolResult) -> Self {
Self {
content: result.content,
structured_content: result.structured_content,
meta: result.meta,
}
}
}
impl From<CoreMcpToolCallError> for McpToolCallError {
fn from(error: CoreMcpToolCallError) -> Self {
Self {
message: error.message,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -6483,6 +6503,23 @@ impl From<CoreTurnItem> for ThreadItem {
.map(PatchApplyStatus::from)
.unwrap_or(PatchApplyStatus::InProgress),
},
CoreTurnItem::McpToolCall(mcp) => {
let duration_ms = mcp
.duration
.and_then(|duration| i64::try_from(duration.as_millis()).ok());
ThreadItem::McpToolCall {
id: mcp.id,
server: mcp.server,
tool: mcp.tool,
status: McpToolCallStatus::from(mcp.status),
arguments: mcp.arguments,
mcp_app_resource_uri: mcp.mcp_app_resource_uri,
result: mcp.result.map(McpToolCallResult::from).map(Box::new),
error: mcp.error.map(McpToolCallError::from),
duration_ms,
}
}
CoreTurnItem::ContextCompaction(compaction) => {
ThreadItem::ContextCompaction { id: compaction.id }
}
@@ -6592,6 +6629,16 @@ impl From<&CorePatchApplyStatus> for PatchApplyStatus {
}
}
impl From<CoreMcpToolCallStatus> for McpToolCallStatus {
fn from(value: CoreMcpToolCallStatus) -> Self {
match value {
CoreMcpToolCallStatus::InProgress => McpToolCallStatus::InProgress,
CoreMcpToolCallStatus::Completed => McpToolCallStatus::Completed,
CoreMcpToolCallStatus::Failed => McpToolCallStatus::Failed,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -8094,10 +8141,13 @@ mod tests {
use codex_protocol::items::AgentMessageItem;
use codex_protocol::items::FileChangeItem;
use codex_protocol::items::ImageViewItem;
use codex_protocol::items::McpToolCallItem;
use codex_protocol::items::McpToolCallStatus as CoreMcpToolCallStatus;
use codex_protocol::items::ReasoningItem;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::items::WebSearchItem;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::models::WebSearchAction as CoreWebSearchAction;
use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess;
use codex_protocol::user_input::UserInput as CoreUserInput;
@@ -8107,6 +8157,7 @@ mod tests {
use serde_json::json;
use std::num::NonZeroUsize;
use std::path::PathBuf;
use std::time::Duration;
fn absolute_path_string(path: &str) -> String {
let path = format!("/{}", path.trim_start_matches('/'));
@@ -10416,6 +10467,69 @@ mod tests {
status: PatchApplyStatus::Completed,
}
);
let mcp_tool_call_item = TurnItem::McpToolCall(McpToolCallItem {
id: "mcp-1".to_string(),
server: "server".to_string(),
tool: "tool".to_string(),
arguments: json!({"arg": "value"}),
mcp_app_resource_uri: Some("app://connector".to_string()),
status: CoreMcpToolCallStatus::InProgress,
result: None,
error: None,
duration: None,
});
assert_eq!(
ThreadItem::from(mcp_tool_call_item),
ThreadItem::McpToolCall {
id: "mcp-1".to_string(),
server: "server".to_string(),
tool: "tool".to_string(),
status: McpToolCallStatus::InProgress,
arguments: json!({"arg": "value"}),
mcp_app_resource_uri: Some("app://connector".to_string()),
result: None,
error: None,
duration_ms: None,
}
);
let completed_mcp_tool_call_item = TurnItem::McpToolCall(McpToolCallItem {
id: "mcp-2".to_string(),
server: "server".to_string(),
tool: "tool".to_string(),
arguments: JsonValue::Null,
mcp_app_resource_uri: None,
status: CoreMcpToolCallStatus::Completed,
result: Some(CallToolResult {
content: vec![json!({"type": "text", "text": "ok"})],
structured_content: Some(json!({"ok": true})),
is_error: Some(false),
meta: Some(json!({"trace": "1"})),
}),
error: None,
duration: Some(Duration::from_millis(42)),
});
assert_eq!(
ThreadItem::from(completed_mcp_tool_call_item),
ThreadItem::McpToolCall {
id: "mcp-2".to_string(),
server: "server".to_string(),
tool: "tool".to_string(),
status: McpToolCallStatus::Completed,
arguments: JsonValue::Null,
mcp_app_resource_uri: None,
result: Some(Box::new(McpToolCallResult {
content: vec![json!({"type": "text", "text": "ok"})],
structured_content: Some(json!({"ok": true})),
meta: Some(json!({"trace": "1"})),
})),
error: None,
duration_ms: Some(42),
}
);
}
#[test]
@@ -1,10 +1,10 @@
use crate::codex_message_processor::read_rollout_items_from_rollout;
use crate::codex_message_processor::read_summary_from_rollout;
use crate::codex_message_processor::summary_to_thread;
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use crate::outgoing_message::ClientRequestResult;
use crate::outgoing_message::ThreadScopedOutgoingMessageSender;
use crate::request_processors::read_rollout_items_from_rollout;
use crate::request_processors::read_summary_from_rollout;
use crate::request_processors::summary_to_thread;
use crate::server_request_error::is_turn_transition_server_request_error;
use crate::thread_state::ThreadState;
use crate::thread_state::TurnSummary;
@@ -836,9 +836,11 @@ pub(crate) async fn apply_bespoke_event_handling(
crate::dynamic_tools::on_call_response(call_id, rx, conversation).await;
});
}
EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) => {
// Deprecated MCP tool-call events are still fanned out for legacy clients.
// App-server v2 receives the canonical TurnItem::McpToolCall lifecycle instead.
}
msg @ (EventMsg::DynamicToolCallResponse(_)
| EventMsg::McpToolCallBegin(_)
| EventMsg::McpToolCallEnd(_)
| EventMsg::CollabAgentSpawnBegin(_)
| EventMsg::CollabAgentSpawnEnd(_)
| EventMsg::CollabAgentInteractionBegin(_)
File diff suppressed because it is too large Load Diff
@@ -1,66 +0,0 @@
use std::sync::Arc;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::AppListUpdatedNotification;
use codex_app_server_protocol::AppsListResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ServerNotification;
use codex_chatgpt::connectors;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::outgoing_message::OutgoingMessageSender;
pub(super) fn merge_loaded_apps(
all_connectors: Option<&[AppInfo]>,
accessible_connectors: Option<&[AppInfo]>,
) -> Vec<AppInfo> {
let all_connectors_loaded = all_connectors.is_some();
let all = all_connectors.map_or_else(Vec::new, <[AppInfo]>::to_vec);
let accessible = accessible_connectors.map_or_else(Vec::new, <[AppInfo]>::to_vec);
connectors::merge_connectors_with_accessible(all, accessible, all_connectors_loaded)
}
pub(super) fn should_send_app_list_updated_notification(
connectors: &[AppInfo],
accessible_loaded: bool,
all_loaded: bool,
) -> bool {
connectors.iter().any(|connector| connector.is_accessible) || (accessible_loaded && all_loaded)
}
pub(super) fn paginate_apps(
connectors: &[AppInfo],
start: usize,
limit: Option<u32>,
) -> Result<AppsListResponse, JSONRPCErrorError> {
let total = connectors.len();
if start > total {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("cursor {start} exceeds total apps {total}"),
data: None,
});
}
let effective_limit = limit.unwrap_or(total as u32).max(1) as usize;
let end = start.saturating_add(effective_limit).min(total);
let data = connectors[start..end].to_vec();
let next_cursor = if end < total {
Some(end.to_string())
} else {
None
};
Ok(AppsListResponse { data, next_cursor })
}
pub(super) async fn send_app_list_updated_notification(
outgoing: &Arc<OutgoingMessageSender>,
data: Vec<AppInfo>,
) {
outgoing
.send_server_notification(ServerNotification::AppListUpdated(
AppListUpdatedNotification { data },
))
.await;
}
@@ -1,149 +0,0 @@
use std::collections::HashSet;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::AppSummary;
use codex_chatgpt::connectors;
use codex_core::config::Config;
use codex_exec_server::EnvironmentManager;
use codex_plugin::AppConnectorId;
use tracing::warn;
pub(super) async fn load_plugin_app_summaries(
config: &Config,
plugin_apps: &[AppConnectorId],
environment_manager: &EnvironmentManager,
) -> Vec<AppSummary> {
if plugin_apps.is_empty() {
return Vec::new();
}
let connectors =
match connectors::list_all_connectors_with_options(config, /*force_refetch*/ false).await {
Ok(connectors) => connectors,
Err(err) => {
warn!("failed to load app metadata for plugin/read: {err:#}");
connectors::list_cached_all_connectors(config)
.await
.unwrap_or_default()
}
};
let plugin_connectors = connectors::connectors_for_plugin_apps(connectors, plugin_apps);
let accessible_connectors =
match connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
config,
/*force_refetch*/ false,
environment_manager,
)
.await
{
Ok(status) if status.codex_apps_ready => status.connectors,
Ok(_) => {
return plugin_connectors
.into_iter()
.map(AppSummary::from)
.collect();
}
Err(err) => {
warn!("failed to load app auth state for plugin/read: {err:#}");
return plugin_connectors
.into_iter()
.map(AppSummary::from)
.collect();
}
};
let accessible_ids = accessible_connectors
.iter()
.map(|connector| connector.id.as_str())
.collect::<HashSet<_>>();
plugin_connectors
.into_iter()
.map(|connector| {
let needs_auth = !accessible_ids.contains(connector.id.as_str());
AppSummary {
id: connector.id,
name: connector.name,
description: connector.description,
install_url: connector.install_url,
needs_auth,
}
})
.collect()
}
pub(super) fn plugin_apps_needing_auth(
all_connectors: &[AppInfo],
accessible_connectors: &[AppInfo],
plugin_apps: &[AppConnectorId],
codex_apps_ready: bool,
) -> Vec<AppSummary> {
if !codex_apps_ready {
return Vec::new();
}
let accessible_ids = accessible_connectors
.iter()
.map(|connector| connector.id.as_str())
.collect::<HashSet<_>>();
let plugin_app_ids = plugin_apps
.iter()
.map(|connector_id| connector_id.0.as_str())
.collect::<HashSet<_>>();
all_connectors
.iter()
.filter(|connector| {
plugin_app_ids.contains(connector.id.as_str())
&& !accessible_ids.contains(connector.id.as_str())
})
.cloned()
.map(|connector| AppSummary {
id: connector.id,
name: connector.name,
description: connector.description,
install_url: connector.install_url,
needs_auth: true,
})
.collect()
}
#[cfg(test)]
mod tests {
use codex_app_server_protocol::AppInfo;
use codex_plugin::AppConnectorId;
use pretty_assertions::assert_eq;
use super::plugin_apps_needing_auth;
#[test]
fn plugin_apps_needing_auth_returns_empty_when_codex_apps_is_not_ready() {
let all_connectors = vec![AppInfo {
id: "alpha".to_string(),
name: "Alpha".to_string(),
description: Some("Alpha connector".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
}];
assert_eq!(
plugin_apps_needing_auth(
&all_connectors,
&[],
&[AppConnectorId("alpha".to_string())],
/*codex_apps_ready*/ false,
),
Vec::new()
);
}
}
@@ -1,95 +0,0 @@
use std::collections::HashMap;
use std::sync::Arc;
use codex_app_server_protocol::McpServerOauthLoginCompletedNotification;
use codex_app_server_protocol::ServerNotification;
use codex_config::types::McpServerConfig;
use codex_core::config::Config;
use codex_mcp::McpOAuthLoginSupport;
use codex_mcp::oauth_login_support;
use codex_mcp::resolve_oauth_scopes;
use codex_mcp::should_retry_without_scopes;
use codex_rmcp_client::perform_oauth_login_silent;
use tracing::warn;
use super::CodexMessageProcessor;
impl CodexMessageProcessor {
pub(super) async fn start_plugin_mcp_oauth_logins(
&self,
config: &Config,
plugin_mcp_servers: HashMap<String, McpServerConfig>,
) {
for (name, server) in plugin_mcp_servers {
let oauth_config = match oauth_login_support(&server.transport).await {
McpOAuthLoginSupport::Supported(config) => config,
McpOAuthLoginSupport::Unsupported => continue,
McpOAuthLoginSupport::Unknown(err) => {
warn!(
"MCP server may or may not require login for plugin install {name}: {err}"
);
continue;
}
};
let resolved_scopes = resolve_oauth_scopes(
/*explicit_scopes*/ None,
server.scopes.clone(),
oauth_config.discovered_scopes.clone(),
);
let store_mode = config.mcp_oauth_credentials_store_mode;
let callback_port = config.mcp_oauth_callback_port;
let callback_url = config.mcp_oauth_callback_url.clone();
let outgoing = Arc::clone(&self.outgoing);
let notification_name = name.clone();
tokio::spawn(async move {
let first_attempt = perform_oauth_login_silent(
&name,
&oauth_config.url,
store_mode,
oauth_config.http_headers.clone(),
oauth_config.env_http_headers.clone(),
&resolved_scopes.scopes,
server.oauth_resource.as_deref(),
callback_port,
callback_url.as_deref(),
)
.await;
let final_result = match first_attempt {
Err(err) if should_retry_without_scopes(&resolved_scopes, &err) => {
perform_oauth_login_silent(
&name,
&oauth_config.url,
store_mode,
oauth_config.http_headers,
oauth_config.env_http_headers,
&[],
server.oauth_resource.as_deref(),
callback_port,
callback_url.as_deref(),
)
.await
}
result => result,
};
let (success, error) = match final_result {
Ok(()) => (true, None),
Err(err) => (false, Some(err.to_string())),
};
let notification = ServerNotification::McpServerOauthLoginCompleted(
McpServerOauthLoginCompletedNotification {
name: notification_name,
success,
error,
},
);
outgoing.send_server_notification(notification).await;
});
}
}
}
-874
View File
@@ -1,874 +0,0 @@
use crate::config_manager::ConfigManager;
use crate::config_manager_service::ConfigManagerError;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use async_trait::async_trait;
use codex_analytics::AnalyticsEventsClient;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigReadResponse;
use codex_app_server_protocol::ConfigRequirements;
use codex_app_server_protocol::ConfigRequirementsReadResponse;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWriteErrorCode;
use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::ConfiguredHookHandler;
use codex_app_server_protocol::ConfiguredHookMatcherGroup;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ManagedHooksRequirements;
use codex_app_server_protocol::NetworkDomainPermission;
use codex_app_server_protocol::NetworkRequirements;
use codex_app_server_protocol::NetworkUnixSocketPermission;
use codex_app_server_protocol::SandboxMode;
use codex_config::ConfigRequirementsToml;
use codex_config::HookEventsToml;
use codex_config::HookHandlerConfig as CoreHookHandlerConfig;
use codex_config::ManagedHooksRequirementsToml;
use codex_config::MatcherGroup as CoreMatcherGroup;
use codex_config::ResidencyRequirement as CoreResidencyRequirement;
use codex_config::SandboxModeRequirement as CoreSandboxModeRequirement;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core_plugins::loader::installed_plugin_telemetry_metadata;
use codex_core_plugins::toggles::collect_plugin_enabled_candidates;
use codex_features::canonical_feature_for_key;
use codex_features::feature_for_key;
use codex_plugin::PluginId;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::protocol::Op;
use serde_json::json;
use std::path::PathBuf;
use std::sync::Arc;
use tracing::warn;
const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[
"apps",
"memories",
"plugins",
"remote_control",
"tool_search",
"tool_suggest",
"tool_call_mcp_elicitation",
];
#[async_trait]
pub(crate) trait UserConfigReloader: Send + Sync {
async fn reload_user_config(&self);
}
#[async_trait]
impl UserConfigReloader for ThreadManager {
async fn reload_user_config(&self) {
let thread_ids = self.list_thread_ids().await;
for thread_id in thread_ids {
let Ok(thread) = self.get_thread(thread_id).await else {
continue;
};
if let Err(err) = thread.submit(Op::ReloadUserConfig).await {
warn!("failed to request user config reload: {err}");
}
}
}
}
#[derive(Clone)]
pub(crate) struct ConfigApi {
config_manager: ConfigManager,
user_config_reloader: Arc<dyn UserConfigReloader>,
analytics_events_client: AnalyticsEventsClient,
}
impl ConfigApi {
pub(crate) fn new(
config_manager: ConfigManager,
user_config_reloader: Arc<dyn UserConfigReloader>,
analytics_events_client: AnalyticsEventsClient,
) -> Self {
Self {
config_manager,
user_config_reloader,
analytics_events_client,
}
}
pub(crate) async fn load_latest_config(
&self,
fallback_cwd: Option<PathBuf>,
) -> Result<Config, JSONRPCErrorError> {
self.config_manager
.load_latest_config(fallback_cwd)
.await
.map_err(|err| {
internal_error(format!(
"failed to resolve feature override precedence: {err}"
))
})
}
pub(crate) async fn read(
&self,
params: ConfigReadParams,
) -> Result<ConfigReadResponse, JSONRPCErrorError> {
let fallback_cwd = params.cwd.as_ref().map(PathBuf::from);
let mut response = self.config_manager.read(params).await.map_err(map_error)?;
let config = self.load_latest_config(fallback_cwd).await?;
for feature_key in SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT {
let Some(feature) = feature_for_key(feature_key) else {
continue;
};
let features = response
.config
.additional
.entry("features".to_string())
.or_insert_with(|| json!({}));
if !features.is_object() {
*features = json!({});
}
if let Some(features) = features.as_object_mut() {
features.insert(
(*feature_key).to_string(),
json!(config.features.enabled(feature)),
);
}
}
Ok(response)
}
pub(crate) async fn config_requirements_read(
&self,
) -> Result<ConfigRequirementsReadResponse, JSONRPCErrorError> {
let requirements = self
.config_manager
.read_requirements()
.await
.map_err(map_error)?
.map(map_requirements_toml_to_api);
Ok(ConfigRequirementsReadResponse { requirements })
}
pub(crate) async fn write_value(
&self,
params: ConfigValueWriteParams,
) -> Result<ConfigWriteResponse, JSONRPCErrorError> {
let pending_changes =
collect_plugin_enabled_candidates([(&params.key_path, &params.value)].into_iter());
let response = self
.config_manager
.write_value(params)
.await
.map_err(map_error)?;
self.emit_plugin_toggle_events(pending_changes).await;
Ok(response)
}
pub(crate) async fn batch_write(
&self,
params: ConfigBatchWriteParams,
) -> Result<ConfigWriteResponse, JSONRPCErrorError> {
let reload_user_config = params.reload_user_config;
let pending_changes = collect_plugin_enabled_candidates(
params
.edits
.iter()
.map(|edit| (&edit.key_path, &edit.value)),
);
let response = self
.config_manager
.batch_write(params)
.await
.map_err(map_error)?;
self.emit_plugin_toggle_events(pending_changes).await;
if reload_user_config {
self.user_config_reloader.reload_user_config().await;
}
Ok(response)
}
pub(crate) async fn set_experimental_feature_enablement(
&self,
params: ExperimentalFeatureEnablementSetParams,
) -> Result<ExperimentalFeatureEnablementSetResponse, JSONRPCErrorError> {
let ExperimentalFeatureEnablementSetParams { enablement } = params;
for key in enablement.keys() {
if canonical_feature_for_key(key).is_some() {
if SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str()) {
continue;
}
return Err(invalid_request(format!(
"unsupported feature enablement `{key}`: currently supported features are {}",
SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.join(", ")
)));
}
let message = if let Some(feature) = feature_for_key(key) {
format!(
"invalid feature enablement `{key}`: use canonical feature key `{}`",
feature.key()
)
} else {
format!("invalid feature enablement `{key}`")
};
return Err(invalid_request(message));
}
if enablement.is_empty() {
return Ok(ExperimentalFeatureEnablementSetResponse { enablement });
}
self.config_manager
.extend_runtime_feature_enablement(
enablement
.iter()
.map(|(name, enabled)| (name.clone(), *enabled)),
)
.map_err(|_| internal_error("failed to update feature enablement"))?;
self.load_latest_config(/*fallback_cwd*/ None).await?;
self.user_config_reloader.reload_user_config().await;
Ok(ExperimentalFeatureEnablementSetResponse { enablement })
}
async fn emit_plugin_toggle_events(
&self,
pending_changes: std::collections::BTreeMap<String, bool>,
) {
for (plugin_id, enabled) in pending_changes {
let Ok(plugin_id) = PluginId::parse(&plugin_id) else {
continue;
};
let metadata =
installed_plugin_telemetry_metadata(self.config_manager.codex_home(), &plugin_id)
.await;
if enabled {
self.analytics_events_client.track_plugin_enabled(metadata);
} else {
self.analytics_events_client.track_plugin_disabled(metadata);
}
}
}
}
fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigRequirements {
ConfigRequirements {
allowed_approval_policies: requirements.allowed_approval_policies.map(|policies| {
policies
.into_iter()
.map(codex_app_server_protocol::AskForApproval::from)
.collect()
}),
allowed_approvals_reviewers: requirements.allowed_approvals_reviewers.map(|reviewers| {
reviewers
.into_iter()
.map(codex_app_server_protocol::ApprovalsReviewer::from)
.collect()
}),
allowed_sandbox_modes: requirements.allowed_sandbox_modes.map(|modes| {
modes
.into_iter()
.filter_map(map_sandbox_mode_requirement_to_api)
.collect()
}),
allowed_web_search_modes: requirements.allowed_web_search_modes.map(|modes| {
let mut normalized = modes
.into_iter()
.map(Into::into)
.collect::<Vec<WebSearchMode>>();
if !normalized.contains(&WebSearchMode::Disabled) {
normalized.push(WebSearchMode::Disabled);
}
normalized
}),
feature_requirements: requirements
.feature_requirements
.map(|requirements| requirements.entries),
hooks: requirements.hooks.map(map_hooks_requirements_to_api),
enforce_residency: requirements
.enforce_residency
.map(map_residency_requirement_to_api),
network: requirements.network.map(map_network_requirements_to_api),
}
}
fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> ManagedHooksRequirements {
let ManagedHooksRequirementsToml {
managed_dir,
windows_managed_dir,
hooks,
} = hooks;
let HookEventsToml {
pre_tool_use,
permission_request,
post_tool_use,
session_start,
user_prompt_submit,
stop,
} = hooks;
ManagedHooksRequirements {
managed_dir,
windows_managed_dir,
pre_tool_use: map_hook_matcher_groups_to_api(pre_tool_use),
permission_request: map_hook_matcher_groups_to_api(permission_request),
post_tool_use: map_hook_matcher_groups_to_api(post_tool_use),
session_start: map_hook_matcher_groups_to_api(session_start),
user_prompt_submit: map_hook_matcher_groups_to_api(user_prompt_submit),
stop: map_hook_matcher_groups_to_api(stop),
}
}
fn map_hook_matcher_groups_to_api(
groups: Vec<CoreMatcherGroup>,
) -> Vec<ConfiguredHookMatcherGroup> {
groups
.into_iter()
.map(map_hook_matcher_group_to_api)
.collect()
}
fn map_hook_matcher_group_to_api(group: CoreMatcherGroup) -> ConfiguredHookMatcherGroup {
ConfiguredHookMatcherGroup {
matcher: group.matcher,
hooks: group
.hooks
.into_iter()
.map(map_hook_handler_to_api)
.collect(),
}
}
fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHandler {
match handler {
CoreHookHandlerConfig::Command {
command,
timeout_sec,
r#async,
status_message,
} => ConfiguredHookHandler::Command {
command,
timeout_sec,
r#async,
status_message,
},
CoreHookHandlerConfig::Prompt {} => ConfiguredHookHandler::Prompt {},
CoreHookHandlerConfig::Agent {} => ConfiguredHookHandler::Agent {},
}
}
fn map_sandbox_mode_requirement_to_api(mode: CoreSandboxModeRequirement) -> Option<SandboxMode> {
match mode {
CoreSandboxModeRequirement::ReadOnly => Some(SandboxMode::ReadOnly),
CoreSandboxModeRequirement::WorkspaceWrite => Some(SandboxMode::WorkspaceWrite),
CoreSandboxModeRequirement::DangerFullAccess => Some(SandboxMode::DangerFullAccess),
CoreSandboxModeRequirement::ExternalSandbox => None,
}
}
fn map_residency_requirement_to_api(
residency: CoreResidencyRequirement,
) -> codex_app_server_protocol::ResidencyRequirement {
match residency {
CoreResidencyRequirement::Us => codex_app_server_protocol::ResidencyRequirement::Us,
}
}
fn map_network_requirements_to_api(
network: codex_config::NetworkRequirementsToml,
) -> NetworkRequirements {
let allowed_domains = network
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains);
let denied_domains = network
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::denied_domains);
let allow_unix_sockets = network
.unix_sockets
.as_ref()
.map(codex_config::NetworkUnixSocketPermissionsToml::allow_unix_sockets)
.filter(|entries| !entries.is_empty());
NetworkRequirements {
enabled: network.enabled,
http_port: network.http_port,
socks_port: network.socks_port,
allow_upstream_proxy: network.allow_upstream_proxy,
dangerously_allow_non_loopback_proxy: network.dangerously_allow_non_loopback_proxy,
dangerously_allow_all_unix_sockets: network.dangerously_allow_all_unix_sockets,
domains: network.domains.map(|domains| {
domains
.entries
.into_iter()
.map(|(pattern, permission)| {
(pattern, map_network_domain_permission_to_api(permission))
})
.collect()
}),
managed_allowed_domains_only: network.managed_allowed_domains_only,
allowed_domains,
denied_domains,
unix_sockets: network.unix_sockets.map(|unix_sockets| {
unix_sockets
.entries
.into_iter()
.map(|(path, permission)| {
(path, map_network_unix_socket_permission_to_api(permission))
})
.collect()
}),
allow_unix_sockets,
allow_local_binding: network.allow_local_binding,
}
}
fn map_network_domain_permission_to_api(
permission: codex_config::NetworkDomainPermissionToml,
) -> NetworkDomainPermission {
match permission {
codex_config::NetworkDomainPermissionToml::Allow => NetworkDomainPermission::Allow,
codex_config::NetworkDomainPermissionToml::Deny => NetworkDomainPermission::Deny,
}
}
fn map_network_unix_socket_permission_to_api(
permission: codex_config::NetworkUnixSocketPermissionToml,
) -> NetworkUnixSocketPermission {
match permission {
codex_config::NetworkUnixSocketPermissionToml::Allow => NetworkUnixSocketPermission::Allow,
codex_config::NetworkUnixSocketPermissionToml::None => NetworkUnixSocketPermission::None,
}
}
fn map_error(err: ConfigManagerError) -> JSONRPCErrorError {
if let Some(code) = err.write_error_code() {
return config_write_error(code, err.to_string());
}
internal_error(err.to_string())
}
fn config_write_error(code: ConfigWriteErrorCode, message: impl Into<String>) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: message.into(),
data: Some(json!({
"config_write_error_code": code,
})),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config_manager::apply_runtime_feature_enablement;
use codex_analytics::AnalyticsEventsClient;
use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudRequirementsLoader;
use codex_config::LoaderOverrides;
use codex_config::NetworkDomainPermissionToml as CoreNetworkDomainPermissionToml;
use codex_config::NetworkDomainPermissionsToml as CoreNetworkDomainPermissionsToml;
use codex_config::NetworkRequirementsToml as CoreNetworkRequirementsToml;
use codex_config::NetworkUnixSocketPermissionToml as CoreNetworkUnixSocketPermissionToml;
use codex_config::NetworkUnixSocketPermissionsToml as CoreNetworkUnixSocketPermissionsToml;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_protocol::config_types::ApprovalsReviewer as CoreApprovalsReviewer;
use codex_protocol::protocol::AskForApproval as CoreAskForApproval;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use tempfile::TempDir;
use toml::Value as TomlValue;
#[derive(Default)]
struct RecordingUserConfigReloader {
call_count: AtomicUsize,
}
#[async_trait]
impl UserConfigReloader for RecordingUserConfigReloader {
async fn reload_user_config(&self) {
self.call_count.fetch_add(1, Ordering::Relaxed);
}
}
#[test]
fn map_requirements_toml_to_api_converts_core_enums() {
let requirements = ConfigRequirementsToml {
allowed_approval_policies: Some(vec![
CoreAskForApproval::Never,
CoreAskForApproval::OnRequest,
]),
allowed_approvals_reviewers: Some(vec![
CoreApprovalsReviewer::User,
CoreApprovalsReviewer::AutoReview,
]),
allowed_sandbox_modes: Some(vec![
CoreSandboxModeRequirement::ReadOnly,
CoreSandboxModeRequirement::ExternalSandbox,
]),
remote_sandbox_config: None,
allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]),
guardian_policy_config: None,
feature_requirements: Some(codex_config::FeatureRequirementsToml {
entries: std::collections::BTreeMap::from([
("apps".to_string(), false),
("personality".to_string(), true),
]),
}),
hooks: Some(ManagedHooksRequirementsToml {
managed_dir: Some(PathBuf::from("/enterprise/hooks")),
windows_managed_dir: Some(PathBuf::from(r"C:\enterprise\hooks")),
hooks: HookEventsToml {
pre_tool_use: vec![CoreMatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![CoreHookHandlerConfig::Command {
command: "python3 /enterprise/hooks/pre.py".to_string(),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
}],
}],
..Default::default()
},
}),
mcp_servers: None,
plugins: None,
apps: None,
rules: None,
enforce_residency: Some(CoreResidencyRequirement::Us),
network: Some(CoreNetworkRequirementsToml {
enabled: Some(true),
http_port: Some(8080),
socks_port: Some(1080),
allow_upstream_proxy: Some(false),
dangerously_allow_non_loopback_proxy: Some(false),
dangerously_allow_all_unix_sockets: Some(true),
domains: Some(CoreNetworkDomainPermissionsToml {
entries: std::collections::BTreeMap::from([
(
"api.openai.com".to_string(),
CoreNetworkDomainPermissionToml::Allow,
),
(
"example.com".to_string(),
CoreNetworkDomainPermissionToml::Deny,
),
]),
}),
managed_allowed_domains_only: Some(false),
unix_sockets: Some(CoreNetworkUnixSocketPermissionsToml {
entries: std::collections::BTreeMap::from([(
"/tmp/proxy.sock".to_string(),
CoreNetworkUnixSocketPermissionToml::Allow,
)]),
}),
allow_local_binding: Some(true),
}),
permissions: None,
};
let mapped = map_requirements_toml_to_api(requirements);
assert_eq!(
mapped.allowed_approval_policies,
Some(vec![
codex_app_server_protocol::AskForApproval::Never,
codex_app_server_protocol::AskForApproval::OnRequest,
])
);
assert_eq!(
mapped.allowed_approvals_reviewers,
Some(vec![
codex_app_server_protocol::ApprovalsReviewer::User,
codex_app_server_protocol::ApprovalsReviewer::AutoReview,
])
);
assert_eq!(
mapped.allowed_sandbox_modes,
Some(vec![SandboxMode::ReadOnly]),
);
assert_eq!(
mapped.allowed_web_search_modes,
Some(vec![WebSearchMode::Cached, WebSearchMode::Disabled]),
);
assert_eq!(
mapped.feature_requirements,
Some(std::collections::BTreeMap::from([
("apps".to_string(), false),
("personality".to_string(), true),
])),
);
assert_eq!(
mapped.hooks,
Some(ManagedHooksRequirements {
managed_dir: Some(PathBuf::from("/enterprise/hooks")),
windows_managed_dir: Some(PathBuf::from(r"C:\enterprise\hooks")),
pre_tool_use: vec![ConfiguredHookMatcherGroup {
matcher: Some("^Bash$".to_string()),
hooks: vec![ConfiguredHookHandler::Command {
command: "python3 /enterprise/hooks/pre.py".to_string(),
timeout_sec: Some(10),
r#async: false,
status_message: Some("checking".to_string()),
}],
}],
permission_request: Vec::new(),
post_tool_use: Vec::new(),
session_start: Vec::new(),
user_prompt_submit: Vec::new(),
stop: Vec::new(),
}),
);
assert_eq!(
mapped.enforce_residency,
Some(codex_app_server_protocol::ResidencyRequirement::Us),
);
assert_eq!(
mapped.network,
Some(NetworkRequirements {
enabled: Some(true),
http_port: Some(8080),
socks_port: Some(1080),
allow_upstream_proxy: Some(false),
dangerously_allow_non_loopback_proxy: Some(false),
dangerously_allow_all_unix_sockets: Some(true),
domains: Some(std::collections::BTreeMap::from([
("api.openai.com".to_string(), NetworkDomainPermission::Allow,),
("example.com".to_string(), NetworkDomainPermission::Deny),
])),
managed_allowed_domains_only: Some(false),
allowed_domains: Some(vec!["api.openai.com".to_string()]),
denied_domains: Some(vec!["example.com".to_string()]),
unix_sockets: Some(std::collections::BTreeMap::from([(
"/tmp/proxy.sock".to_string(),
NetworkUnixSocketPermission::Allow,
)])),
allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]),
allow_local_binding: Some(true),
}),
);
}
#[test]
fn map_requirements_toml_to_api_omits_unix_socket_none_entries_from_legacy_network_fields() {
let requirements = ConfigRequirementsToml {
allowed_approval_policies: None,
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
remote_sandbox_config: None,
allowed_web_search_modes: None,
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
plugins: None,
apps: None,
rules: None,
enforce_residency: None,
network: Some(CoreNetworkRequirementsToml {
enabled: None,
http_port: None,
socks_port: None,
allow_upstream_proxy: None,
dangerously_allow_non_loopback_proxy: None,
dangerously_allow_all_unix_sockets: None,
domains: None,
managed_allowed_domains_only: None,
unix_sockets: Some(CoreNetworkUnixSocketPermissionsToml {
entries: std::collections::BTreeMap::from([(
"/tmp/ignored.sock".to_string(),
CoreNetworkUnixSocketPermissionToml::None,
)]),
}),
allow_local_binding: None,
}),
permissions: None,
};
let mapped = map_requirements_toml_to_api(requirements);
assert_eq!(
mapped.network,
Some(NetworkRequirements {
enabled: None,
http_port: None,
socks_port: None,
allow_upstream_proxy: None,
dangerously_allow_non_loopback_proxy: None,
dangerously_allow_all_unix_sockets: None,
domains: None,
managed_allowed_domains_only: None,
allowed_domains: None,
denied_domains: None,
unix_sockets: Some(std::collections::BTreeMap::from([(
"/tmp/ignored.sock".to_string(),
NetworkUnixSocketPermission::None,
)])),
allow_unix_sockets: None,
allow_local_binding: None,
}),
);
}
#[test]
fn map_requirements_toml_to_api_normalizes_allowed_web_search_modes() {
let requirements = ConfigRequirementsToml {
allowed_approval_policies: None,
allowed_approvals_reviewers: None,
allowed_sandbox_modes: None,
remote_sandbox_config: None,
allowed_web_search_modes: Some(Vec::new()),
guardian_policy_config: None,
feature_requirements: None,
hooks: None,
mcp_servers: None,
plugins: None,
apps: None,
rules: None,
enforce_residency: None,
network: None,
permissions: None,
};
let mapped = map_requirements_toml_to_api(requirements);
assert_eq!(
mapped.allowed_web_search_modes,
Some(vec![WebSearchMode::Disabled])
);
}
#[tokio::test]
async fn apply_runtime_feature_enablement_keeps_cli_overrides_above_config_and_runtime() {
let codex_home = TempDir::new().expect("create temp dir");
std::fs::write(
codex_home.path().join("config.toml"),
"[features]\napps = false\n",
)
.expect("write config");
let mut config = codex_core::config::ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cli_overrides(vec![(
"features.apps".to_string(),
TomlValue::Boolean(true),
)])
.build()
.await
.expect("load config");
apply_runtime_feature_enablement(
&mut config,
&BTreeMap::from([("apps".to_string(), false)]),
);
assert!(config.features.enabled(Feature::Apps));
}
#[tokio::test]
async fn apply_runtime_feature_enablement_keeps_cloud_pins_above_cli_and_runtime() {
let codex_home = TempDir::new().expect("create temp dir");
let mut config = codex_core::config::ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.cli_overrides(vec![(
"features.apps".to_string(),
TomlValue::Boolean(true),
)])
.cloud_requirements(CloudRequirementsLoader::new(async {
Ok(Some(ConfigRequirementsToml {
feature_requirements: Some(codex_config::FeatureRequirementsToml {
entries: BTreeMap::from([("apps".to_string(), false)]),
}),
..Default::default()
}))
}))
.build()
.await
.expect("load config");
apply_runtime_feature_enablement(
&mut config,
&BTreeMap::from([("apps".to_string(), true)]),
);
assert!(!config.features.enabled(Feature::Apps));
}
#[tokio::test]
async fn batch_write_reloads_user_config_when_requested() {
let codex_home = TempDir::new().expect("create temp dir");
let user_config_path = codex_home.path().join("config.toml");
std::fs::write(&user_config_path, "").expect("write config");
let reloader = Arc::new(RecordingUserConfigReloader::default());
let analytics_config = Arc::new(
codex_core::config::ConfigBuilder::default()
.build()
.await
.expect("load analytics config"),
);
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
let config_api = ConfigApi::new(
ConfigManager::new(
codex_home.path().to_path_buf(),
Vec::new(),
LoaderOverrides::default(),
CloudRequirementsLoader::default(),
Arg0DispatchPaths::default(),
Arc::new(codex_config::NoopThreadConfigLoader),
),
reloader.clone(),
AnalyticsEventsClient::new(
auth_manager,
analytics_config
.chatgpt_base_url
.trim_end_matches('/')
.to_string(),
analytics_config.analytics_enabled,
),
);
let response = config_api
.batch_write(ConfigBatchWriteParams {
edits: vec![codex_app_server_protocol::ConfigEdit {
key_path: "model".to_string(),
value: json!("gpt-5"),
merge_strategy: codex_app_server_protocol::MergeStrategy::Replace,
}],
file_path: Some(user_config_path.display().to_string()),
expected_version: None,
reload_user_config: true,
})
.await
.expect("batch write should succeed");
assert_eq!(
response,
ConfigWriteResponse {
status: codex_app_server_protocol::WriteStatus::Ok,
version: response.version.clone(),
file_path: codex_utils_absolute_path::AbsolutePathBuf::try_from(
user_config_path.clone()
)
.expect("absolute config path"),
overridden_metadata: None,
}
);
assert_eq!(
std::fs::read_to_string(user_config_path).unwrap(),
"model = \"gpt-5\"\n"
);
assert_eq!(reloader.call_count.load(Ordering::Relaxed), 1);
}
}
+1 -1
View File
@@ -804,7 +804,7 @@ mod tests {
}
#[tokio::test]
async fn in_process_allows_device_key_requests_to_reach_device_key_api() {
async fn in_process_allows_device_key_requests_to_reach_device_key_processor() {
let client = start_test_client(SessionSource::Cli).await;
const MALFORMED_KEY_ID_MESSAGE: &str = concat!(
"invalid device key payload: keyId must be dk_hse_, dk_tpm_, or dk_osn_ ",
+1 -5
View File
@@ -73,25 +73,21 @@ use tracing_subscriber::util::SubscriberInitExt;
mod analytics_utils;
mod app_server_tracing;
mod bespoke_event_handling;
mod codex_message_processor;
mod command_exec;
mod config;
mod config_api;
mod config_manager;
mod config_manager_service;
mod connection_rpc_gate;
mod device_key_api;
mod dynamic_tools;
mod error_code;
mod external_agent_config_api;
mod filters;
mod fs_api;
mod fs_watch;
mod fuzzy_file_search;
pub mod in_process;
mod message_processor;
mod models;
mod outgoing_message;
mod request_processors;
mod request_serialization;
mod server_request_error;
mod thread_state;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,481 @@
use crate::bespoke_event_handling::apply_bespoke_event_handling;
use crate::bespoke_event_handling::maybe_emit_hook_prompt_item_completed;
use crate::command_exec::CommandExecManager;
use crate::command_exec::StartCommandExecParams;
use crate::config_manager::ConfigManager;
use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_PARAMS_ERROR_CODE;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::error_code::invalid_params;
use crate::models::supported_models;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::ConnectionRequestId;
use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::RequestContext;
use crate::outgoing_message::ThreadScopedOutgoingMessageSender;
use crate::thread_status::ThreadWatchManager;
use crate::thread_status::resolve_thread_status;
use chrono::DateTime;
use chrono::Duration as ChronoDuration;
use chrono::SecondsFormat;
use chrono::Utc;
use codex_analytics::AnalyticsEventsClient;
use codex_analytics::AnalyticsJsonRpcError;
use codex_analytics::InputError;
use codex_analytics::TurnSteerRequestError;
use codex_app_server_protocol::Account;
use codex_app_server_protocol::AccountLoginCompletedNotification;
use codex_app_server_protocol::AccountUpdatedNotification;
use codex_app_server_protocol::AddCreditsNudgeCreditType;
use codex_app_server_protocol::AddCreditsNudgeEmailStatus;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::AppListUpdatedNotification;
use codex_app_server_protocol::AppSummary;
use codex_app_server_protocol::AppsListParams;
use codex_app_server_protocol::AppsListResponse;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::CancelLoginAccountParams;
use codex_app_server_protocol::CancelLoginAccountResponse;
use codex_app_server_protocol::CancelLoginAccountStatus;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ClientResponsePayload;
use codex_app_server_protocol::CodexErrorInfo;
use codex_app_server_protocol::CollaborationModeListParams;
use codex_app_server_protocol::CollaborationModeListResponse;
use codex_app_server_protocol::CommandExecParams;
use codex_app_server_protocol::CommandExecResizeParams;
use codex_app_server_protocol::CommandExecTerminateParams;
use codex_app_server_protocol::CommandExecWriteParams;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::ConversationGitInfo;
use codex_app_server_protocol::ConversationSummary;
use codex_app_server_protocol::DynamicToolSpec as ApiDynamicToolSpec;
use codex_app_server_protocol::ExperimentalFeature as ApiExperimentalFeature;
use codex_app_server_protocol::ExperimentalFeatureListParams;
use codex_app_server_protocol::ExperimentalFeatureListResponse;
use codex_app_server_protocol::ExperimentalFeatureStage as ApiExperimentalFeatureStage;
use codex_app_server_protocol::FeedbackUploadParams;
use codex_app_server_protocol::FeedbackUploadResponse;
use codex_app_server_protocol::GetAccountParams;
use codex_app_server_protocol::GetAccountRateLimitsResponse;
use codex_app_server_protocol::GetAccountResponse;
use codex_app_server_protocol::GetAuthStatusParams;
use codex_app_server_protocol::GetAuthStatusResponse;
use codex_app_server_protocol::GetConversationSummaryParams;
use codex_app_server_protocol::GetConversationSummaryResponse;
use codex_app_server_protocol::GitDiffToRemoteParams;
use codex_app_server_protocol::GitDiffToRemoteResponse;
use codex_app_server_protocol::GitInfo as ApiGitInfo;
use codex_app_server_protocol::HookMetadata;
use codex_app_server_protocol::HooksListParams;
use codex_app_server_protocol::HooksListResponse;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::InitializeResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::ListMcpServerStatusResponse;
use codex_app_server_protocol::LoginAccountParams;
use codex_app_server_protocol::LoginAccountResponse;
use codex_app_server_protocol::LoginApiKeyParams;
use codex_app_server_protocol::LogoutAccountResponse;
use codex_app_server_protocol::MarketplaceAddParams;
use codex_app_server_protocol::MarketplaceAddResponse;
use codex_app_server_protocol::MarketplaceInterface;
use codex_app_server_protocol::MarketplaceRemoveParams;
use codex_app_server_protocol::MarketplaceRemoveResponse;
use codex_app_server_protocol::MarketplaceUpgradeErrorInfo;
use codex_app_server_protocol::MarketplaceUpgradeParams;
use codex_app_server_protocol::MarketplaceUpgradeResponse;
use codex_app_server_protocol::McpResourceReadParams;
use codex_app_server_protocol::McpResourceReadResponse;
use codex_app_server_protocol::McpServerOauthLoginCompletedNotification;
use codex_app_server_protocol::McpServerOauthLoginParams;
use codex_app_server_protocol::McpServerOauthLoginResponse;
use codex_app_server_protocol::McpServerRefreshResponse;
use codex_app_server_protocol::McpServerStatus;
use codex_app_server_protocol::McpServerStatusDetail;
use codex_app_server_protocol::McpServerToolCallParams;
use codex_app_server_protocol::McpServerToolCallResponse;
use codex_app_server_protocol::MemoryResetResponse;
use codex_app_server_protocol::MockExperimentalMethodParams;
use codex_app_server_protocol::MockExperimentalMethodResponse;
use codex_app_server_protocol::ModelListParams;
use codex_app_server_protocol::ModelListResponse;
use codex_app_server_protocol::PermissionProfileModificationParams;
use codex_app_server_protocol::PermissionProfileSelectionParams;
use codex_app_server_protocol::PluginDetail;
use codex_app_server_protocol::PluginInstallParams;
use codex_app_server_protocol::PluginInstallResponse;
use codex_app_server_protocol::PluginInterface;
use codex_app_server_protocol::PluginListParams;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginMarketplaceEntry;
use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginShareDeleteParams;
use codex_app_server_protocol::PluginShareDeleteResponse;
use codex_app_server_protocol::PluginShareListItem;
use codex_app_server_protocol::PluginShareListParams;
use codex_app_server_protocol::PluginShareListResponse;
use codex_app_server_protocol::PluginShareSaveParams;
use codex_app_server_protocol::PluginShareSaveResponse;
use codex_app_server_protocol::PluginSkillReadParams;
use codex_app_server_protocol::PluginSkillReadResponse;
use codex_app_server_protocol::PluginSource;
use codex_app_server_protocol::PluginSummary;
use codex_app_server_protocol::PluginUninstallParams;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery;
use codex_app_server_protocol::ReviewStartParams;
use codex_app_server_protocol::ReviewStartResponse;
use codex_app_server_protocol::ReviewTarget as ApiReviewTarget;
use codex_app_server_protocol::SandboxMode;
use codex_app_server_protocol::SendAddCreditsNudgeEmailParams;
use codex_app_server_protocol::SendAddCreditsNudgeEmailResponse;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequestResolvedNotification;
use codex_app_server_protocol::SkillSummary;
use codex_app_server_protocol::SkillsConfigWriteParams;
use codex_app_server_protocol::SkillsConfigWriteResponse;
use codex_app_server_protocol::SkillsListParams;
use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::SortDirection;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadApproveGuardianDeniedActionParams;
use codex_app_server_protocol::ThreadApproveGuardianDeniedActionResponse;
use codex_app_server_protocol::ThreadArchiveParams;
use codex_app_server_protocol::ThreadArchiveResponse;
use codex_app_server_protocol::ThreadArchivedNotification;
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams;
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse;
use codex_app_server_protocol::ThreadClosedNotification;
use codex_app_server_protocol::ThreadCompactStartParams;
use codex_app_server_protocol::ThreadCompactStartResponse;
use codex_app_server_protocol::ThreadDecrementElicitationParams;
use codex_app_server_protocol::ThreadDecrementElicitationResponse;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadGoal;
use codex_app_server_protocol::ThreadGoalClearParams;
use codex_app_server_protocol::ThreadGoalClearResponse;
use codex_app_server_protocol::ThreadGoalClearedNotification;
use codex_app_server_protocol::ThreadGoalGetParams;
use codex_app_server_protocol::ThreadGoalGetResponse;
use codex_app_server_protocol::ThreadGoalSetParams;
use codex_app_server_protocol::ThreadGoalSetResponse;
use codex_app_server_protocol::ThreadGoalStatus;
use codex_app_server_protocol::ThreadGoalUpdatedNotification;
use codex_app_server_protocol::ThreadIncrementElicitationParams;
use codex_app_server_protocol::ThreadIncrementElicitationResponse;
use codex_app_server_protocol::ThreadInjectItemsParams;
use codex_app_server_protocol::ThreadInjectItemsResponse;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadListCwdFilter;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadLoadedListResponse;
use codex_app_server_protocol::ThreadMemoryModeSetParams;
use codex_app_server_protocol::ThreadMemoryModeSetResponse;
use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams;
use codex_app_server_protocol::ThreadMetadataUpdateParams;
use codex_app_server_protocol::ThreadMetadataUpdateResponse;
use codex_app_server_protocol::ThreadNameUpdatedNotification;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadRealtimeAppendAudioParams;
use codex_app_server_protocol::ThreadRealtimeAppendAudioResponse;
use codex_app_server_protocol::ThreadRealtimeAppendTextParams;
use codex_app_server_protocol::ThreadRealtimeAppendTextResponse;
use codex_app_server_protocol::ThreadRealtimeListVoicesResponse;
use codex_app_server_protocol::ThreadRealtimeStartParams;
use codex_app_server_protocol::ThreadRealtimeStartResponse;
use codex_app_server_protocol::ThreadRealtimeStartTransport;
use codex_app_server_protocol::ThreadRealtimeStopParams;
use codex_app_server_protocol::ThreadRealtimeStopResponse;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadSetNameParams;
use codex_app_server_protocol::ThreadSetNameResponse;
use codex_app_server_protocol::ThreadShellCommandParams;
use codex_app_server_protocol::ThreadShellCommandResponse;
use codex_app_server_protocol::ThreadSortKey;
use codex_app_server_protocol::ThreadSourceKind;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStartedNotification;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadTurnsListParams;
use codex_app_server_protocol::ThreadTurnsListResponse;
use codex_app_server_protocol::ThreadUnarchiveParams;
use codex_app_server_protocol::ThreadUnarchiveResponse;
use codex_app_server_protocol::ThreadUnarchivedNotification;
use codex_app_server_protocol::ThreadUnsubscribeParams;
use codex_app_server_protocol::ThreadUnsubscribeResponse;
use codex_app_server_protocol::ThreadUnsubscribeStatus;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnEnvironmentParams;
use codex_app_server_protocol::TurnError;
use codex_app_server_protocol::TurnInterruptParams;
use codex_app_server_protocol::TurnInterruptResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::TurnSteerParams;
use codex_app_server_protocol::TurnSteerResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_app_server_protocol::WindowsSandboxSetupCompletedNotification;
use codex_app_server_protocol::WindowsSandboxSetupMode;
use codex_app_server_protocol::WindowsSandboxSetupStartParams;
use codex_app_server_protocol::WindowsSandboxSetupStartResponse;
use codex_app_server_protocol::build_turns_from_rollout_items;
use codex_arg0::Arg0DispatchPaths;
use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType;
use codex_backend_client::Client as BackendClient;
use codex_chatgpt::connectors;
use codex_chatgpt::workspace_settings;
use codex_config::CloudRequirementsLoadError;
use codex_config::CloudRequirementsLoadErrorCode;
use codex_config::ConfigLayerStack;
use codex_config::loader::project_trust_key;
use codex_config::types::McpServerTransportConfig;
use codex_core::CodexThread;
use codex_core::CodexThreadTurnContextOverrides;
use codex_core::ForkSnapshot;
use codex_core::NewThread;
use codex_core::RolloutRecorder;
use codex_core::SessionMeta;
use codex_core::StartThreadOptions;
use codex_core::SteerInputError;
use codex_core::ThreadConfigSnapshot;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::NetworkProxyAuditMetadata;
use codex_core::config::edit::ConfigEdit;
use codex_core::config::edit::ConfigEditsBuilder;
use codex_core::exec::ExecCapturePolicy;
use codex_core::exec::ExecExpiration;
use codex_core::exec::ExecParams;
use codex_core::exec_env::create_env;
use codex_core::find_archived_thread_path_by_id_str;
use codex_core::find_thread_name_by_id;
use codex_core::find_thread_path_by_id_str;
use codex_core::path_utils;
use codex_core::read_head_for_summary;
use codex_core::sandboxing::SandboxPermissions;
use codex_core::windows_sandbox::WindowsSandboxLevelExt;
use codex_core::windows_sandbox::WindowsSandboxSetupMode as CoreWindowsSandboxSetupMode;
use codex_core::windows_sandbox::WindowsSandboxSetupRequest;
use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
use codex_core_plugins::PluginInstallError as CorePluginInstallError;
use codex_core_plugins::PluginInstallRequest;
use codex_core_plugins::PluginLoadOutcome;
use codex_core_plugins::PluginReadRequest;
use codex_core_plugins::PluginUninstallError as CorePluginUninstallError;
use codex_core_plugins::loader::load_plugin_apps;
use codex_core_plugins::loader::load_plugin_mcp_servers;
use codex_core_plugins::loader::plugin_telemetry_metadata_from_root;
use codex_core_plugins::manifest::PluginManifestInterface;
use codex_core_plugins::marketplace::MarketplaceError;
use codex_core_plugins::marketplace::MarketplacePluginSource;
use codex_core_plugins::marketplace_add::MarketplaceAddError;
use codex_core_plugins::marketplace_add::MarketplaceAddRequest;
use codex_core_plugins::marketplace_add::add_marketplace as add_marketplace_to_codex_home;
use codex_core_plugins::marketplace_remove::MarketplaceRemoveError;
use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest as CoreMarketplaceRemoveRequest;
use codex_core_plugins::marketplace_remove::remove_marketplace;
use codex_core_plugins::remote::RemoteMarketplace;
use codex_core_plugins::remote::RemotePluginCatalogError;
use codex_core_plugins::remote::RemotePluginDetail as RemoteCatalogPluginDetail;
use codex_core_plugins::remote::RemotePluginServiceConfig;
use codex_core_plugins::remote::RemotePluginShareSummary as RemoteCatalogPluginShareSummary;
use codex_core_plugins::remote::RemotePluginSummary as RemoteCatalogPluginSummary;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::LOCAL_FS;
use codex_features::FEATURES;
use codex_features::Feature;
use codex_features::Stage;
use codex_feedback::CodexFeedback;
use codex_feedback::FeedbackAttachmentPath;
use codex_feedback::FeedbackUploadOptions;
use codex_git_utils::git_diff_to_remote;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_login::AuthManager;
use codex_login::CLIENT_ID;
use codex_login::CodexAuth;
use codex_login::ServerOptions as LoginServerOptions;
use codex_login::ShutdownHandle;
use codex_login::auth::login_with_chatgpt_auth_tokens;
use codex_login::complete_device_code_login;
use codex_login::login_with_api_key;
use codex_login::request_device_code;
use codex_login::run_login_server;
use codex_mcp::McpRuntimeEnvironment;
use codex_mcp::McpServerStatusSnapshot;
use codex_mcp::McpSnapshotDetail;
use codex_mcp::collect_mcp_server_status_snapshot_with_detail;
use codex_mcp::discover_supported_scopes;
use codex_mcp::effective_mcp_servers;
use codex_mcp::read_mcp_resource as read_mcp_resource_without_thread;
use codex_mcp::resolve_oauth_scopes;
use codex_memories_write::clear_memory_roots_contents;
use codex_model_provider::ProviderAccountError;
use codex_model_provider::create_model_provider;
use codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets;
use codex_protocol::ThreadId;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::dynamic_tools::DynamicToolSpec as CoreDynamicToolSpec;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::protocol::AgentStatus;
use codex_protocol::protocol::ConversationAudioParams;
use codex_protocol::protocol::ConversationStartParams;
use codex_protocol::protocol::ConversationStartTransport;
use codex_protocol::protocol::ConversationTextParams;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::GitInfo as CoreGitInfo;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus;
use codex_protocol::protocol::McpServerRefreshConfig;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
use codex_protocol::protocol::RealtimeVoicesList;
use codex_protocol::protocol::ResumedHistory;
use codex_protocol::protocol::ReviewDelivery as CoreReviewDelivery;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::ReviewTarget as CoreReviewTarget;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionConfiguredEvent;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::TurnEnvironmentSelection;
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
use codex_protocol::protocol::W3cTraceContext;
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
use codex_protocol::user_input::UserInput as CoreInputItem;
use codex_rmcp_client::perform_oauth_login_return_url;
use codex_rollout::state_db::StateDbHandle;
use codex_rollout::state_db::get_state_db;
use codex_rollout::state_db::reconcile_rollout;
use codex_state::StateRuntime;
use codex_state::ThreadMetadata;
use codex_state::ThreadMetadataBuilder;
use codex_state::log_db::LogDbLayer;
use codex_thread_store::ArchiveThreadParams as StoreArchiveThreadParams;
use codex_thread_store::ListThreadsParams as StoreListThreadsParams;
use codex_thread_store::LocalThreadStore;
use codex_thread_store::ReadThreadByRolloutPathParams as StoreReadThreadByRolloutPathParams;
use codex_thread_store::ReadThreadParams as StoreReadThreadParams;
use codex_thread_store::SortDirection as StoreSortDirection;
use codex_thread_store::StoredThread;
use codex_thread_store::ThreadMetadataPatch as StoreThreadMetadataPatch;
use codex_thread_store::ThreadSortKey as StoreThreadSortKey;
use codex_thread_store::ThreadStore;
use codex_thread_store::ThreadStoreError;
use codex_thread_store::UpdateThreadMetadataParams as StoreUpdateThreadMetadataParams;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
use std::collections::HashMap;
use std::collections::HashSet;
use std::io::Error as IoError;
use std::path::Path;
use std::path::PathBuf;
use std::result::Result;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use tokio::sync::Mutex;
use tokio::sync::Semaphore;
use tokio::sync::SemaphorePermit;
use tokio::sync::broadcast;
use tokio::sync::oneshot;
use tokio::sync::watch;
use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker;
use toml::Value as TomlValue;
use tracing::Instrument;
use tracing::error;
use tracing::info;
use tracing::warn;
use uuid::Uuid;
#[cfg(test)]
use codex_app_server_protocol::ServerRequest;
mod account_processor;
mod apps_processor;
mod catalog_processor;
mod command_exec_processor;
mod config_processor;
mod device_key_processor;
mod external_agent_config_processor;
mod feedback_processor;
mod fs_processor;
mod git_processor;
mod initialize_processor;
mod marketplace_processor;
mod mcp_processor;
mod plugins;
mod search;
mod thread_processor;
mod token_usage_replay;
mod turn_processor;
mod windows_sandbox_processor;
pub(crate) use account_processor::AccountRequestProcessor;
pub(crate) use apps_processor::AppsRequestProcessor;
pub(crate) use catalog_processor::CatalogRequestProcessor;
pub(crate) use command_exec_processor::CommandExecRequestProcessor;
pub(crate) use config_processor::ConfigRequestProcessor;
pub(crate) use device_key_processor::DeviceKeyRequestProcessor;
pub(crate) use external_agent_config_processor::ExternalAgentConfigRequestProcessor;
pub(crate) use feedback_processor::FeedbackRequestProcessor;
pub(crate) use fs_processor::FsRequestProcessor;
pub(crate) use git_processor::GitRequestProcessor;
pub(crate) use initialize_processor::InitializeRequestProcessor;
pub(crate) use marketplace_processor::MarketplaceRequestProcessor;
pub(crate) use mcp_processor::McpRequestProcessor;
pub(crate) use plugins::PluginRequestProcessor;
pub(crate) use search::SearchRequestProcessor;
pub(crate) use thread_goal_processor::ThreadGoalRequestProcessor;
pub(crate) use thread_processor::ThreadRequestProcessor;
pub(crate) use turn_processor::TurnRequestProcessor;
pub(crate) use windows_sandbox_processor::WindowsSandboxRequestProcessor;
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use crate::filters::compute_source_filters;
use crate::filters::source_kind_matches;
use crate::thread_state::ThreadListenerCommand;
use crate::thread_state::ThreadState;
use crate::thread_state::ThreadStateManager;
use token_usage_replay::latest_token_usage_turn_id_from_rollout_items;
use token_usage_replay::send_thread_token_usage_update_to_connection;
mod config_errors;
mod request_errors;
mod thread_goal_processor;
mod thread_lifecycle;
mod thread_summary;
use self::config_errors::*;
use self::request_errors::*;
use self::thread_goal_processor::api_thread_goal_from_state;
use self::thread_lifecycle::*;
use self::thread_summary::*;
pub(crate) use self::thread_summary::read_rollout_items_from_rollout;
pub(crate) use self::thread_summary::read_summary_from_rollout;
pub(crate) use self::thread_summary::summary_to_thread;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,350 @@
use super::*;
#[derive(Clone)]
pub(crate) struct AppsRequestProcessor {
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
outgoing: Arc<OutgoingMessageSender>,
config_manager: ConfigManager,
workspace_settings_cache: Arc<workspace_settings::WorkspaceSettingsCache>,
}
impl AppsRequestProcessor {
pub(crate) fn new(
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
outgoing: Arc<OutgoingMessageSender>,
config_manager: ConfigManager,
workspace_settings_cache: Arc<workspace_settings::WorkspaceSettingsCache>,
) -> Self {
Self {
auth_manager,
thread_manager,
outgoing,
config_manager,
workspace_settings_cache,
}
}
pub(crate) async fn apps_list(
&self,
request_id: &ConnectionRequestId,
params: AppsListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.apps_list_inner(request_id, params)
.await
.map(|response| response.map(Into::into))
}
async fn apps_list_inner(
&self,
request_id: &ConnectionRequestId,
params: AppsListParams,
) -> Result<Option<AppsListResponse>, JSONRPCErrorError> {
let mut config = self.load_latest_config(/*fallback_cwd*/ None).await?;
if let Some(thread_id) = params.thread_id.as_deref() {
let (_, thread) = self.load_thread(thread_id).await?;
let _ = config
.features
.set_enabled(Feature::Apps, thread.enabled(Feature::Apps));
}
let auth = self.auth_manager.auth().await;
if !config
.features
.apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend))
{
return Ok(Some(AppsListResponse {
data: Vec::new(),
next_cursor: None,
}));
}
if !self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
.await
{
return Ok(Some(AppsListResponse {
data: Vec::new(),
next_cursor: None,
}));
}
let request = request_id.clone();
let outgoing = Arc::clone(&self.outgoing);
let environment_manager = self.thread_manager.environment_manager();
tokio::spawn(async move {
Self::apps_list_task(outgoing, request, params, config, environment_manager).await;
});
Ok(None)
}
async fn apps_list_task(
outgoing: Arc<OutgoingMessageSender>,
request_id: ConnectionRequestId,
params: AppsListParams,
config: Config,
environment_manager: Arc<EnvironmentManager>,
) {
let result = Self::apps_list_response(&outgoing, params, config, environment_manager).await;
outgoing.send_result(request_id, result).await;
}
async fn apps_list_response(
outgoing: &Arc<OutgoingMessageSender>,
params: AppsListParams,
config: Config,
environment_manager: Arc<EnvironmentManager>,
) -> Result<AppsListResponse, JSONRPCErrorError> {
let AppsListParams {
cursor,
limit,
thread_id: _,
force_refetch,
} = params;
let start = match cursor {
Some(cursor) => match cursor.parse::<usize>() {
Ok(idx) => idx,
Err(_) => return Err(invalid_request(format!("invalid cursor: {cursor}"))),
},
None => 0,
};
let (mut accessible_connectors, mut all_connectors) = tokio::join!(
connectors::list_cached_accessible_connectors_from_mcp_tools(&config),
connectors::list_cached_all_connectors(&config)
);
let cached_all_connectors = all_connectors.clone();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let accessible_config = config.clone();
let accessible_tx = tx.clone();
tokio::spawn(async move {
let result =
connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
&accessible_config,
force_refetch,
&environment_manager,
)
.await
.map(|status| status.connectors)
.map_err(|err| format!("failed to load accessible apps: {err}"));
let _ = accessible_tx.send(AppListLoadResult::Accessible(result));
});
let all_config = config.clone();
tokio::spawn(async move {
let result = connectors::list_all_connectors_with_options(&all_config, force_refetch)
.await
.map_err(|err| format!("failed to list apps: {err}"));
let _ = tx.send(AppListLoadResult::Directory(result));
});
let app_list_deadline = tokio::time::Instant::now() + APP_LIST_LOAD_TIMEOUT;
let mut accessible_loaded = false;
let mut all_loaded = false;
let mut last_notified_apps = None;
if accessible_connectors.is_some() || all_connectors.is_some() {
let merged = connectors::with_app_enabled_state(
merge_loaded_apps(all_connectors.as_deref(), accessible_connectors.as_deref()),
&config,
);
if should_send_app_list_updated_notification(
merged.as_slice(),
accessible_loaded,
all_loaded,
) {
send_app_list_updated_notification(outgoing, merged.clone()).await;
last_notified_apps = Some(merged);
}
}
loop {
let result = match tokio::time::timeout_at(app_list_deadline, rx.recv()).await {
Ok(Some(result)) => result,
Ok(None) => {
return Err(internal_error("failed to load app lists"));
}
Err(_) => {
let timeout_seconds = APP_LIST_LOAD_TIMEOUT.as_secs();
return Err(internal_error(format!(
"timed out waiting for app lists after {timeout_seconds} seconds"
)));
}
};
match result {
AppListLoadResult::Accessible(Ok(connectors)) => {
accessible_connectors = Some(connectors);
accessible_loaded = true;
}
AppListLoadResult::Accessible(Err(err)) => {
return Err(internal_error(err));
}
AppListLoadResult::Directory(Ok(connectors)) => {
all_connectors = Some(connectors);
all_loaded = true;
}
AppListLoadResult::Directory(Err(err)) => {
return Err(internal_error(err));
}
}
let showing_interim_force_refetch = force_refetch && !(accessible_loaded && all_loaded);
let all_connectors_for_update =
if showing_interim_force_refetch && cached_all_connectors.is_some() {
cached_all_connectors.as_deref()
} else {
all_connectors.as_deref()
};
let accessible_connectors_for_update =
if showing_interim_force_refetch && !accessible_loaded {
None
} else {
accessible_connectors.as_deref()
};
let merged = connectors::with_app_enabled_state(
merge_loaded_apps(all_connectors_for_update, accessible_connectors_for_update),
&config,
);
if should_send_app_list_updated_notification(
merged.as_slice(),
accessible_loaded,
all_loaded,
) && last_notified_apps.as_ref() != Some(&merged)
{
send_app_list_updated_notification(outgoing, merged.clone()).await;
last_notified_apps = Some(merged.clone());
}
if accessible_loaded && all_loaded {
return paginate_apps(merged.as_slice(), start, limit);
}
}
}
async fn load_thread(
&self,
thread_id: &str,
) -> Result<(ThreadId, Arc<CodexThread>), JSONRPCErrorError> {
let thread_id = ThreadId::from_string(thread_id).map_err(|err| JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("invalid thread id: {err}"),
data: None,
})?;
let thread = self
.thread_manager
.get_thread(thread_id)
.await
.map_err(|_| JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("thread not found: {thread_id}"),
data: None,
})?;
Ok((thread_id, thread))
}
async fn load_latest_config(
&self,
fallback_cwd: Option<PathBuf>,
) -> Result<Config, JSONRPCErrorError> {
self.config_manager
.load_latest_config(fallback_cwd)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to reload config: {err}"),
data: None,
})
}
async fn workspace_codex_plugins_enabled(
&self,
config: &Config,
auth: Option<&CodexAuth>,
) -> bool {
match workspace_settings::codex_plugins_enabled_for_workspace(
config,
auth,
Some(&self.workspace_settings_cache),
)
.await
{
Ok(enabled) => enabled,
Err(err) => {
warn!(
"failed to fetch workspace Codex plugins setting; allowing Codex plugins: {err:#}"
);
true
}
}
}
}
const APP_LIST_LOAD_TIMEOUT: Duration = Duration::from_secs(90);
enum AppListLoadResult {
Accessible(Result<Vec<AppInfo>, String>),
Directory(Result<Vec<AppInfo>, String>),
}
fn merge_loaded_apps(
all_connectors: Option<&[AppInfo]>,
accessible_connectors: Option<&[AppInfo]>,
) -> Vec<AppInfo> {
let all_connectors_loaded = all_connectors.is_some();
let all = all_connectors.map_or_else(Vec::new, <[AppInfo]>::to_vec);
let accessible = accessible_connectors.map_or_else(Vec::new, <[AppInfo]>::to_vec);
connectors::merge_connectors_with_accessible(all, accessible, all_connectors_loaded)
}
fn should_send_app_list_updated_notification(
connectors: &[AppInfo],
accessible_loaded: bool,
all_loaded: bool,
) -> bool {
connectors.iter().any(|connector| connector.is_accessible) || (accessible_loaded && all_loaded)
}
fn paginate_apps(
connectors: &[AppInfo],
start: usize,
limit: Option<u32>,
) -> Result<AppsListResponse, JSONRPCErrorError> {
let total = connectors.len();
if start > total {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("cursor {start} exceeds total apps {total}"),
data: None,
});
}
let effective_limit = limit.unwrap_or(total as u32).max(1) as usize;
let end = start.saturating_add(effective_limit).min(total);
let data = connectors[start..end].to_vec();
let next_cursor = if end < total {
Some(end.to_string())
} else {
None
};
Ok(AppsListResponse { data, next_cursor })
}
async fn send_app_list_updated_notification(
outgoing: &Arc<OutgoingMessageSender>,
data: Vec<AppInfo>,
) {
outgoing
.send_server_notification(ServerNotification::AppListUpdated(
AppListUpdatedNotification { data },
))
.await;
}
@@ -0,0 +1,600 @@
use super::*;
#[derive(Clone)]
pub(crate) struct CatalogRequestProcessor {
pub(super) auth_manager: Arc<AuthManager>,
pub(super) thread_manager: Arc<ThreadManager>,
pub(super) config: Arc<Config>,
pub(super) config_manager: ConfigManager,
pub(super) workspace_settings_cache: Arc<workspace_settings::WorkspaceSettingsCache>,
}
fn skills_to_info(
skills: &[codex_core::skills::SkillMetadata],
disabled_paths: &HashSet<AbsolutePathBuf>,
) -> Vec<codex_app_server_protocol::SkillMetadata> {
skills
.iter()
.map(|skill| {
let enabled = !disabled_paths.contains(&skill.path_to_skills_md);
codex_app_server_protocol::SkillMetadata {
name: skill.name.clone(),
description: skill.description.clone(),
short_description: skill.short_description.clone(),
interface: skill.interface.clone().map(|interface| {
codex_app_server_protocol::SkillInterface {
display_name: interface.display_name,
short_description: interface.short_description,
icon_small: interface.icon_small,
icon_large: interface.icon_large,
brand_color: interface.brand_color,
default_prompt: interface.default_prompt,
}
}),
dependencies: skill.dependencies.clone().map(|dependencies| {
codex_app_server_protocol::SkillDependencies {
tools: dependencies
.tools
.into_iter()
.map(|tool| codex_app_server_protocol::SkillToolDependency {
r#type: tool.r#type,
value: tool.value,
description: tool.description,
transport: tool.transport,
command: tool.command,
url: tool.url,
})
.collect(),
}
}),
path: skill.path_to_skills_md.clone(),
scope: skill.scope.into(),
enabled,
}
})
.collect()
}
fn hooks_to_info(hooks: &[codex_hooks::HookListEntry]) -> Vec<HookMetadata> {
hooks
.iter()
.map(|hook| HookMetadata {
key: hook.key.clone(),
event_name: hook.event_name.into(),
handler_type: hook.handler_type.into(),
matcher: hook.matcher.clone(),
command: hook.command.clone(),
timeout_sec: hook.timeout_sec,
status_message: hook.status_message.clone(),
source_path: hook.source_path.clone(),
source: hook.source.into(),
plugin_id: hook.plugin_id.clone(),
display_order: hook.display_order,
enabled: hook.enabled,
is_managed: hook.is_managed,
})
.collect()
}
fn errors_to_info(
errors: &[codex_core::skills::SkillError],
) -> Vec<codex_app_server_protocol::SkillErrorInfo> {
errors
.iter()
.map(|err| codex_app_server_protocol::SkillErrorInfo {
path: err.path.to_path_buf(),
message: err.message.clone(),
})
.collect()
}
impl CatalogRequestProcessor {
pub(crate) fn new(
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
config: Arc<Config>,
config_manager: ConfigManager,
workspace_settings_cache: Arc<workspace_settings::WorkspaceSettingsCache>,
) -> Self {
Self {
auth_manager,
thread_manager,
config,
config_manager,
workspace_settings_cache,
}
}
pub(crate) async fn skills_list(
&self,
params: SkillsListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.skills_list_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn hooks_list(
&self,
params: HooksListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.hooks_list_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn skills_config_write(
&self,
params: SkillsConfigWriteParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.skills_config_write_response_inner(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn model_list(
&self,
params: ModelListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
Self::list_models(self.thread_manager.clone(), params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn experimental_feature_list(
&self,
params: ExperimentalFeatureListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.experimental_feature_list_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn collaboration_mode_list(
&self,
params: CollaborationModeListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
Self::list_collaboration_modes(self.thread_manager.clone(), params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn mock_experimental_method(
&self,
params: MockExperimentalMethodParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.mock_experimental_method_inner(params)
.await
.map(|response| Some(response.into()))
}
async fn resolve_cwd_config(
&self,
cwd: &Path,
) -> Result<(AbsolutePathBuf, ConfigLayerStack), String> {
let cwd_abs =
AbsolutePathBuf::relative_to_current_dir(cwd).map_err(|err| err.to_string())?;
let config_layer_stack = self
.config_manager
.load_config_layers_for_cwd(cwd_abs.clone())
.await
.map_err(|err| err.to_string())?;
Ok((cwd_abs, config_layer_stack))
}
async fn load_latest_config(
&self,
fallback_cwd: Option<PathBuf>,
) -> Result<Config, JSONRPCErrorError> {
self.config_manager
.load_latest_config(fallback_cwd)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to reload config: {err}"),
data: None,
})
}
async fn workspace_codex_plugins_enabled(
&self,
config: &Config,
auth: Option<&CodexAuth>,
) -> bool {
match workspace_settings::codex_plugins_enabled_for_workspace(
config,
auth,
Some(&self.workspace_settings_cache),
)
.await
{
Ok(enabled) => enabled,
Err(err) => {
warn!(
"failed to fetch workspace Codex plugins setting; allowing Codex plugins: {err:#}"
);
true
}
}
}
async fn list_models(
thread_manager: Arc<ThreadManager>,
params: ModelListParams,
) -> Result<ModelListResponse, JSONRPCErrorError> {
let ModelListParams {
limit,
cursor,
include_hidden,
} = params;
let models = supported_models(thread_manager, include_hidden.unwrap_or(false)).await;
let total = models.len();
if total == 0 {
return Ok(ModelListResponse {
data: Vec::new(),
next_cursor: None,
});
}
let effective_limit = limit.unwrap_or(total as u32).max(1) as usize;
let effective_limit = effective_limit.min(total);
let start = match cursor {
Some(cursor) => cursor
.parse::<usize>()
.map_err(|_| invalid_request(format!("invalid cursor: {cursor}")))?,
None => 0,
};
if start > total {
return Err(invalid_request(format!(
"cursor {start} exceeds total models {total}"
)));
}
let end = start.saturating_add(effective_limit).min(total);
let items = models[start..end].to_vec();
let next_cursor = if end < total {
Some(end.to_string())
} else {
None
};
Ok(ModelListResponse {
data: items,
next_cursor,
})
}
async fn list_collaboration_modes(
thread_manager: Arc<ThreadManager>,
params: CollaborationModeListParams,
) -> Result<CollaborationModeListResponse, JSONRPCErrorError> {
let CollaborationModeListParams {} = params;
let items = thread_manager
.list_collaboration_modes()
.into_iter()
.map(Into::into)
.collect();
let response = CollaborationModeListResponse { data: items };
Ok(response)
}
async fn experimental_feature_list_response(
&self,
params: ExperimentalFeatureListParams,
) -> Result<ExperimentalFeatureListResponse, JSONRPCErrorError> {
let ExperimentalFeatureListParams { cursor, limit } = params;
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
let auth = self.auth_manager.auth().await;
let workspace_codex_plugins_enabled = self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
.await;
let data = FEATURES
.iter()
.map(|spec| {
let (stage, display_name, description, announcement) = match spec.stage {
Stage::Experimental {
name,
menu_description,
announcement,
} => (
ApiExperimentalFeatureStage::Beta,
Some(name.to_string()),
Some(menu_description.to_string()),
Some(announcement.to_string()),
),
Stage::UnderDevelopment => (
ApiExperimentalFeatureStage::UnderDevelopment,
None,
None,
None,
),
Stage::Stable => (ApiExperimentalFeatureStage::Stable, None, None, None),
Stage::Deprecated => {
(ApiExperimentalFeatureStage::Deprecated, None, None, None)
}
Stage::Removed => (ApiExperimentalFeatureStage::Removed, None, None, None),
};
ApiExperimentalFeature {
name: spec.key.to_string(),
stage,
display_name,
description,
announcement,
enabled: config.features.enabled(spec.id)
&& (workspace_codex_plugins_enabled
|| !matches!(spec.id, Feature::Apps | Feature::Plugins)),
default_enabled: spec.default_enabled,
}
})
.collect::<Vec<_>>();
let total = data.len();
if total == 0 {
return Ok(ExperimentalFeatureListResponse {
data: Vec::new(),
next_cursor: None,
});
}
// Clamp to 1 so limit=0 cannot return a non-advancing page.
let effective_limit = limit.unwrap_or(total as u32).max(1) as usize;
let effective_limit = effective_limit.min(total);
let start = match cursor {
Some(cursor) => match cursor.parse::<usize>() {
Ok(idx) => idx,
Err(_) => return Err(invalid_request(format!("invalid cursor: {cursor}"))),
},
None => 0,
};
if start > total {
return Err(invalid_request(format!(
"cursor {start} exceeds total feature flags {total}"
)));
}
let end = start.saturating_add(effective_limit).min(total);
let data = data[start..end].to_vec();
let next_cursor = if end < total {
Some(end.to_string())
} else {
None
};
Ok(ExperimentalFeatureListResponse { data, next_cursor })
}
async fn mock_experimental_method_inner(
&self,
params: MockExperimentalMethodParams,
) -> Result<MockExperimentalMethodResponse, JSONRPCErrorError> {
let MockExperimentalMethodParams { value } = params;
let response = MockExperimentalMethodResponse { echoed: value };
Ok(response)
}
async fn skills_list_response(
&self,
params: SkillsListParams,
) -> Result<SkillsListResponse, JSONRPCErrorError> {
let SkillsListParams {
cwds,
force_reload,
per_cwd_extra_user_roots,
} = params;
let cwds = if cwds.is_empty() {
vec![self.config.cwd.to_path_buf()]
} else {
cwds
};
let cwd_set: HashSet<PathBuf> = cwds.iter().cloned().collect();
let mut extra_roots_by_cwd: HashMap<PathBuf, Vec<AbsolutePathBuf>> = HashMap::new();
for entry in per_cwd_extra_user_roots.unwrap_or_default() {
if !cwd_set.contains(&entry.cwd) {
warn!(
cwd = %entry.cwd.display(),
"ignoring per-cwd extra roots for cwd not present in skills/list cwds"
);
continue;
}
let mut valid_extra_roots = Vec::new();
for root in entry.extra_user_roots {
let root =
AbsolutePathBuf::from_absolute_path_checked(root.as_path()).map_err(|_| {
invalid_request(format!(
"skills/list perCwdExtraUserRoots extraUserRoots paths must be absolute: {}",
root.display()
))
})?;
valid_extra_roots.push(root);
}
extra_roots_by_cwd
.entry(entry.cwd)
.or_default()
.extend(valid_extra_roots);
}
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
let auth = self.auth_manager.auth().await;
let workspace_codex_plugins_enabled = self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
.await;
let skills_manager = self.thread_manager.skills_manager();
let plugins_manager = self.thread_manager.plugins_manager();
let fs = self
.thread_manager
.environment_manager()
.default_environment()
.map(|environment| environment.get_filesystem());
let mut data = Vec::new();
for cwd in cwds {
let (cwd_abs, config_layer_stack) = match self.resolve_cwd_config(&cwd).await {
Ok(resolved) => resolved,
Err(message) => {
let error_path = cwd.clone();
data.push(codex_app_server_protocol::SkillsListEntry {
cwd,
skills: Vec::new(),
errors: vec![codex_app_server_protocol::SkillErrorInfo {
path: error_path,
message,
}],
});
continue;
}
};
let extra_roots = extra_roots_by_cwd
.get(&cwd)
.map_or(&[][..], std::vec::Vec::as_slice);
let effective_skill_roots = if workspace_codex_plugins_enabled {
let plugins_input = config.plugins_config_input();
plugins_manager
.effective_skill_roots_for_layer_stack(&config_layer_stack, &plugins_input)
.await
} else {
Vec::new()
};
let skills_input = codex_core::skills::SkillsLoadInput::new(
cwd_abs.clone(),
effective_skill_roots,
config_layer_stack,
config.bundled_skills_enabled(),
);
let outcome = skills_manager
.skills_for_cwd_with_extra_user_roots(
&skills_input,
force_reload,
extra_roots,
fs.clone(),
)
.await;
let errors = errors_to_info(&outcome.errors);
let skills = skills_to_info(&outcome.skills, &outcome.disabled_paths);
data.push(codex_app_server_protocol::SkillsListEntry {
cwd,
skills,
errors,
});
}
Ok(SkillsListResponse { data })
}
/// Handle `hooks/list` by resolving hooks for each requested cwd.
async fn hooks_list_response(
&self,
params: HooksListParams,
) -> Result<HooksListResponse, JSONRPCErrorError> {
let HooksListParams { cwds } = params;
let cwds = if cwds.is_empty() {
vec![self.config.cwd.to_path_buf()]
} else {
cwds
};
let auth = self.auth_manager.auth().await;
let plugins_manager = self.thread_manager.plugins_manager();
let mut data = Vec::new();
for cwd in cwds {
let config = match self
.config_manager
.load_for_cwd(
/*request_overrides*/ None,
ConfigOverrides::default(),
Some(cwd.clone()),
)
.await
{
Ok(config) => config,
Err(err) => {
let error_path = cwd.clone();
data.push(codex_app_server_protocol::HooksListEntry {
cwd,
hooks: Vec::new(),
warnings: Vec::new(),
errors: vec![codex_app_server_protocol::HookErrorInfo {
path: error_path,
message: err.to_string(),
}],
});
continue;
}
};
let workspace_codex_plugins_enabled = self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
.await;
let plugins_enabled =
config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled;
let plugin_outcome = if plugins_enabled && config.features.enabled(Feature::PluginHooks)
{
let plugins_input = config.plugins_config_input();
plugins_manager
.plugins_for_layer_stack(
&config.config_layer_stack,
&plugins_input,
/*plugin_hooks_feature_enabled*/ true,
)
.await
} else {
PluginLoadOutcome::default()
};
let hooks = codex_hooks::list_hooks(codex_hooks::HooksConfig {
feature_enabled: config.features.enabled(Feature::CodexHooks),
config_layer_stack: Some(config.config_layer_stack),
plugin_hook_sources: plugin_outcome.effective_plugin_hook_sources(),
plugin_hook_load_warnings: plugin_outcome.effective_plugin_hook_warnings(),
..Default::default()
});
data.push(codex_app_server_protocol::HooksListEntry {
cwd,
hooks: hooks_to_info(&hooks.hooks),
warnings: hooks.warnings,
errors: Vec::new(),
});
}
Ok(HooksListResponse { data })
}
async fn skills_config_write_response_inner(
&self,
params: SkillsConfigWriteParams,
) -> Result<SkillsConfigWriteResponse, JSONRPCErrorError> {
let SkillsConfigWriteParams {
path,
name,
enabled,
} = params;
let edit = match (path, name) {
(Some(path), None) => ConfigEdit::SetSkillConfig {
path: path.into_path_buf(),
enabled,
},
(None, Some(name)) if !name.trim().is_empty() => {
ConfigEdit::SetSkillConfigByName { name, enabled }
}
_ => {
return Err(invalid_params(
"skills/config/write requires exactly one of path or name",
));
}
};
let edits = vec![edit];
ConfigEditsBuilder::new(&self.config.codex_home)
.with_edits(edits)
.apply()
.await
.map(|()| {
self.thread_manager.plugins_manager().clear_cache();
self.thread_manager.skills_manager().clear_cache();
SkillsConfigWriteResponse {
effective_enabled: enabled,
}
})
.map_err(|err| internal_error(format!("failed to update skill settings: {err}")))
}
}
@@ -0,0 +1,321 @@
use super::*;
#[derive(Clone)]
pub(crate) struct CommandExecRequestProcessor {
arg0_paths: Arg0DispatchPaths,
config: Arc<Config>,
outgoing: Arc<OutgoingMessageSender>,
command_exec_manager: CommandExecManager,
}
impl CommandExecRequestProcessor {
pub(crate) fn new(
arg0_paths: Arg0DispatchPaths,
config: Arc<Config>,
outgoing: Arc<OutgoingMessageSender>,
) -> Self {
Self {
arg0_paths,
config,
outgoing,
command_exec_manager: CommandExecManager::default(),
}
}
pub(crate) async fn one_off_command_exec(
&self,
request_id: &ConnectionRequestId,
params: CommandExecParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.exec_one_off_command(request_id, params)
.await
.map(|()| None)
}
pub(crate) async fn command_exec_write(
&self,
request_id: ConnectionRequestId,
params: CommandExecWriteParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.command_exec_manager
.write(request_id, params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn command_exec_resize(
&self,
request_id: ConnectionRequestId,
params: CommandExecResizeParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.command_exec_manager
.resize(request_id, params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn command_exec_terminate(
&self,
request_id: ConnectionRequestId,
params: CommandExecTerminateParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.command_exec_manager
.terminate(request_id, params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) {
self.command_exec_manager
.connection_closed(connection_id)
.await;
}
async fn exec_one_off_command(
&self,
request_id: &ConnectionRequestId,
params: CommandExecParams,
) -> Result<(), JSONRPCErrorError> {
self.exec_one_off_command_inner(request_id.clone(), params)
.await
}
async fn exec_one_off_command_inner(
&self,
request_id: ConnectionRequestId,
params: CommandExecParams,
) -> Result<(), JSONRPCErrorError> {
tracing::debug!("ExecOneOffCommand params: {params:?}");
let request = request_id.clone();
if params.command.is_empty() {
return Err(invalid_request("command must not be empty"));
}
let CommandExecParams {
command,
process_id,
tty,
stream_stdin,
stream_stdout_stderr,
output_bytes_cap,
disable_output_cap,
disable_timeout,
timeout_ms,
cwd,
env: env_overrides,
size,
sandbox_policy,
permission_profile,
} = params;
if sandbox_policy.is_some() && permission_profile.is_some() {
return Err(invalid_request(
"`permissionProfile` cannot be combined with `sandboxPolicy`",
));
}
if size.is_some() && !tty {
return Err(invalid_params("command/exec size requires tty: true"));
}
if disable_output_cap && output_bytes_cap.is_some() {
return Err(invalid_params(
"command/exec cannot set both outputBytesCap and disableOutputCap",
));
}
if disable_timeout && timeout_ms.is_some() {
return Err(invalid_params(
"command/exec cannot set both timeoutMs and disableTimeout",
));
}
let cwd = cwd.map_or_else(|| self.config.cwd.clone(), |cwd| self.config.cwd.join(cwd));
let mut env = create_env(
&self.config.permissions.shell_environment_policy,
/*thread_id*/ None,
);
if let Some(env_overrides) = env_overrides {
for (key, value) in env_overrides {
match value {
Some(value) => {
env.insert(key, value);
}
None => {
env.remove(&key);
}
}
}
}
let timeout_ms = match timeout_ms {
Some(timeout_ms) => match u64::try_from(timeout_ms) {
Ok(timeout_ms) => Some(timeout_ms),
Err(_) => {
return Err(invalid_params(format!(
"command/exec timeoutMs must be non-negative, got {timeout_ms}"
)));
}
},
None => None,
};
let managed_network_requirements_enabled =
self.config.managed_network_requirements_enabled();
let started_network_proxy = match self.config.permissions.network.as_ref() {
Some(spec) => match spec
.start_proxy(
self.config.permissions.permission_profile.get(),
/*policy_decider*/ None,
/*blocked_request_observer*/ None,
managed_network_requirements_enabled,
NetworkProxyAuditMetadata::default(),
)
.await
{
Ok(started) => Some(started),
Err(err) => {
return Err(internal_error(format!(
"failed to start managed network proxy: {err}"
)));
}
},
None => None,
};
let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config);
let output_bytes_cap = if disable_output_cap {
None
} else {
Some(output_bytes_cap.unwrap_or(DEFAULT_OUTPUT_BYTES_CAP))
};
let expiration = if disable_timeout {
ExecExpiration::Cancellation(CancellationToken::new())
} else {
match timeout_ms {
Some(timeout_ms) => timeout_ms.into(),
None => ExecExpiration::DefaultTimeout,
}
};
let capture_policy = if disable_output_cap {
ExecCapturePolicy::FullBuffer
} else {
ExecCapturePolicy::ShellTool
};
let sandbox_cwd = if permission_profile.is_some() {
cwd.clone()
} else {
self.config.cwd.clone()
};
let exec_params = ExecParams {
command,
cwd: cwd.clone(),
expiration,
capture_policy,
env,
network: started_network_proxy
.as_ref()
.map(codex_core::config::StartedNetworkProxy::proxy),
sandbox_permissions: SandboxPermissions::UseDefault,
windows_sandbox_level,
windows_sandbox_private_desktop: self
.config
.permissions
.windows_sandbox_private_desktop,
justification: None,
arg0: None,
};
let effective_permission_profile = if let Some(permission_profile) = permission_profile {
let permission_profile =
codex_protocol::models::PermissionProfile::from(permission_profile);
let (mut file_system_sandbox_policy, network_sandbox_policy) =
permission_profile.to_runtime_permissions();
let configured_file_system_sandbox_policy =
self.config.permissions.file_system_sandbox_policy();
Self::preserve_configured_deny_read_restrictions(
&mut file_system_sandbox_policy,
&configured_file_system_sandbox_policy,
);
let effective_permission_profile =
codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement(
permission_profile.enforcement(),
&file_system_sandbox_policy,
network_sandbox_policy,
);
self.config
.permissions
.permission_profile
.can_set(&effective_permission_profile)
.map_err(|err| invalid_request(format!("invalid permission profile: {err}")))?;
effective_permission_profile
} else if let Some(policy) = sandbox_policy.map(|policy| policy.to_core()) {
self.config
.permissions
.can_set_legacy_sandbox_policy(&policy, &sandbox_cwd)
.map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?;
let file_system_sandbox_policy =
codex_protocol::permissions::FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&policy, &sandbox_cwd);
let network_sandbox_policy =
codex_protocol::permissions::NetworkSandboxPolicy::from(&policy);
let permission_profile =
codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement(
codex_protocol::models::SandboxEnforcement::from_legacy_sandbox_policy(&policy),
&file_system_sandbox_policy,
network_sandbox_policy,
);
self.config
.permissions
.permission_profile
.can_set(&permission_profile)
.map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?;
permission_profile
} else {
self.config.permissions.permission_profile()
};
let codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone();
let outgoing = self.outgoing.clone();
let request_for_task = request.clone();
let started_network_proxy_for_task = started_network_proxy;
let use_legacy_landlock = self.config.features.use_legacy_landlock();
let size = match size.map(crate::command_exec::terminal_size_from_protocol) {
Some(Ok(size)) => Some(size),
Some(Err(error)) => return Err(error),
None => None,
};
let exec_request = codex_core::exec::build_exec_request(
exec_params,
&effective_permission_profile,
&sandbox_cwd,
&codex_linux_sandbox_exe,
use_legacy_landlock,
)
.map_err(|err| internal_error(format!("exec failed: {err}")))?;
self.command_exec_manager
.start(StartCommandExecParams {
outgoing,
request_id: request_for_task,
process_id,
exec_request,
started_network_proxy: started_network_proxy_for_task,
tty,
stream_stdin,
stream_stdout_stderr,
output_bytes_cap,
size,
})
.await
}
fn preserve_configured_deny_read_restrictions(
file_system_sandbox_policy: &mut FileSystemSandboxPolicy,
configured_file_system_sandbox_policy: &FileSystemSandboxPolicy,
) {
file_system_sandbox_policy
.preserve_deny_read_restrictions_from(configured_file_system_sandbox_policy);
}
}
#[cfg(test)]
#[path = "command_exec_processor_tests.rs"]
mod command_exec_processor_tests;
@@ -0,0 +1,38 @@
use super::*;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
#[test]
fn command_profile_preserves_configured_deny_read_restrictions() {
let readable_entry = FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: test_path_buf("/tmp/project").abs(),
},
access: FileSystemAccessMode::Read,
};
let deny_entry = FileSystemSandboxEntry {
path: FileSystemPath::GlobPattern {
pattern: "/tmp/project/**/*.env".to_string(),
},
access: FileSystemAccessMode::None,
};
let mut file_system_sandbox_policy =
FileSystemSandboxPolicy::restricted(vec![readable_entry.clone()]);
let mut configured_file_system_sandbox_policy =
FileSystemSandboxPolicy::restricted(vec![deny_entry.clone()]);
configured_file_system_sandbox_policy.glob_scan_max_depth = Some(2);
CommandExecRequestProcessor::preserve_configured_deny_read_restrictions(
&mut file_system_sandbox_policy,
&configured_file_system_sandbox_policy,
);
let mut expected = FileSystemSandboxPolicy::restricted(vec![readable_entry, deny_entry]);
expected.glob_scan_max_depth = Some(2);
assert_eq!(file_system_sandbox_policy, expected);
}
@@ -0,0 +1,37 @@
use super::*;
fn cloud_requirements_load_error(err: &std::io::Error) -> Option<&CloudRequirementsLoadError> {
let mut current: Option<&(dyn std::error::Error + 'static)> = err
.get_ref()
.map(|source| source as &(dyn std::error::Error + 'static));
while let Some(source) = current {
if let Some(cloud_error) = source.downcast_ref::<CloudRequirementsLoadError>() {
return Some(cloud_error);
}
current = source.source();
}
None
}
pub(super) fn config_load_error(err: &std::io::Error) -> JSONRPCErrorError {
let data = cloud_requirements_load_error(err).map(|cloud_error| {
let mut data = serde_json::json!({
"reason": "cloudRequirements",
"errorCode": format!("{:?}", cloud_error.code()),
"detail": cloud_error.to_string(),
});
if let Some(status_code) = cloud_error.status_code() {
data["statusCode"] = serde_json::json!(status_code);
}
if cloud_error.code() == CloudRequirementsLoadErrorCode::Auth {
data["action"] = serde_json::json!("relogin");
}
data
});
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("failed to load configuration: {err}"),
data,
}
}
@@ -0,0 +1,622 @@
use std::sync::Arc;
use crate::config_manager::ConfigManager;
use crate::config_manager_service::ConfigManagerError;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use crate::outgoing_message::ConnectionRequestId;
use crate::outgoing_message::OutgoingMessageSender;
use crate::transport::RemoteControlHandle;
use codex_analytics::AnalyticsEventsClient;
use codex_app_server_protocol::AppListUpdatedNotification;
use codex_app_server_protocol::ClientResponsePayload;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigReadResponse;
use codex_app_server_protocol::ConfigRequirements;
use codex_app_server_protocol::ConfigRequirementsReadResponse;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ConfigWriteErrorCode;
use codex_app_server_protocol::ConfigWriteResponse;
use codex_app_server_protocol::ConfiguredHookHandler;
use codex_app_server_protocol::ConfiguredHookMatcherGroup;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams;
use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ManagedHooksRequirements;
use codex_app_server_protocol::ModelProviderCapabilitiesReadResponse;
use codex_app_server_protocol::NetworkDomainPermission;
use codex_app_server_protocol::NetworkRequirements;
use codex_app_server_protocol::NetworkUnixSocketPermission;
use codex_app_server_protocol::SandboxMode;
use codex_app_server_protocol::ServerNotification;
use codex_chatgpt::connectors;
use codex_config::ConfigRequirementsToml;
use codex_config::HookEventsToml;
use codex_config::HookHandlerConfig as CoreHookHandlerConfig;
use codex_config::ManagedHooksRequirementsToml;
use codex_config::MatcherGroup as CoreMatcherGroup;
use codex_config::ResidencyRequirement as CoreResidencyRequirement;
use codex_config::SandboxModeRequirement as CoreSandboxModeRequirement;
use codex_core::ThreadManager;
use codex_features::Feature;
use codex_features::canonical_feature_for_key;
use codex_features::feature_for_key;
use codex_login::AuthManager;
use codex_model_provider::create_model_provider;
use codex_plugin::PluginId;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::protocol::Op;
use serde_json::json;
use std::path::PathBuf;
const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[
"apps",
"memories",
"plugins",
"remote_control",
"tool_search",
"tool_suggest",
"tool_call_mcp_elicitation",
];
#[derive(Clone)]
pub(crate) struct ConfigRequestProcessor {
outgoing: Arc<OutgoingMessageSender>,
config_manager: ConfigManager,
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
analytics_events_client: AnalyticsEventsClient,
remote_control_handle: Option<RemoteControlHandle>,
}
impl ConfigRequestProcessor {
pub(crate) fn new(
outgoing: Arc<OutgoingMessageSender>,
config_manager: ConfigManager,
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
analytics_events_client: AnalyticsEventsClient,
remote_control_handle: Option<RemoteControlHandle>,
) -> Self {
Self {
outgoing,
config_manager,
auth_manager,
thread_manager,
analytics_events_client,
remote_control_handle,
}
}
pub(crate) async fn read(
&self,
params: ConfigReadParams,
) -> Result<ConfigReadResponse, JSONRPCErrorError> {
let fallback_cwd = params.cwd.as_ref().map(PathBuf::from);
let mut response = self.config_manager.read(params).await.map_err(map_error)?;
let config = self.load_latest_config(fallback_cwd).await?;
for feature_key in SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT {
let Some(feature) = feature_for_key(feature_key) else {
continue;
};
let features = response
.config
.additional
.entry("features".to_string())
.or_insert_with(|| json!({}));
if !features.is_object() {
*features = json!({});
}
if let Some(features) = features.as_object_mut() {
features.insert(
(*feature_key).to_string(),
json!(config.features.enabled(feature)),
);
}
}
Ok(response)
}
pub(crate) async fn config_requirements_read(
&self,
) -> Result<ConfigRequirementsReadResponse, JSONRPCErrorError> {
let requirements = self
.config_manager
.read_requirements()
.await
.map_err(map_error)?
.map(map_requirements_toml_to_api);
Ok(ConfigRequirementsReadResponse { requirements })
}
pub(crate) async fn value_write(
&self,
params: ConfigValueWriteParams,
) -> Result<ClientResponsePayload, JSONRPCErrorError> {
self.handle_config_mutation_result(self.write_value(params).await)
.await
.map(ClientResponsePayload::ConfigValueWrite)
}
pub(crate) async fn batch_write(
&self,
params: ConfigBatchWriteParams,
) -> Result<ClientResponsePayload, JSONRPCErrorError> {
self.handle_config_mutation_result(self.batch_write_inner(params).await)
.await
.map(ClientResponsePayload::ConfigBatchWrite)
}
pub(crate) async fn experimental_feature_enablement_set(
&self,
request_id: ConnectionRequestId,
params: ExperimentalFeatureEnablementSetParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
let should_refresh_apps_list = params.enablement.get("apps").copied() == Some(true);
let response = self
.handle_config_mutation_result(self.set_experimental_feature_enablement(params).await)
.await?;
self.outgoing
.send_response_as(
request_id,
ClientResponsePayload::ExperimentalFeatureEnablementSet(response),
)
.await;
if should_refresh_apps_list {
self.refresh_apps_list_after_experimental_feature_enablement_set()
.await;
}
Ok(None)
}
pub(crate) async fn model_provider_capabilities_read(
&self,
) -> Result<ModelProviderCapabilitiesReadResponse, JSONRPCErrorError> {
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
let provider = create_model_provider(config.model_provider, /*auth_manager*/ None);
let capabilities = provider.capabilities();
Ok(ModelProviderCapabilitiesReadResponse {
namespace_tools: capabilities.namespace_tools,
image_generation: capabilities.image_generation,
web_search: capabilities.web_search,
})
}
pub(crate) async fn handle_config_mutation(&self) {
self.thread_manager.plugins_manager().clear_cache();
self.thread_manager.skills_manager().clear_cache();
let Some(remote_control_handle) = &self.remote_control_handle else {
return;
};
match self.load_latest_config(/*fallback_cwd*/ None).await {
Ok(config) => {
remote_control_handle.set_enabled(config.features.enabled(Feature::RemoteControl));
}
Err(error) => {
tracing::warn!(
"failed to load config for remote control enablement refresh after config mutation: {}",
error.message
);
}
}
}
async fn handle_config_mutation_result<T>(
&self,
result: std::result::Result<T, JSONRPCErrorError>,
) -> Result<T, JSONRPCErrorError> {
let response = result?;
self.handle_config_mutation().await;
Ok(response)
}
async fn refresh_apps_list_after_experimental_feature_enablement_set(&self) {
let config = match self.load_latest_config(/*fallback_cwd*/ None).await {
Ok(config) => config,
Err(error) => {
tracing::warn!(
"failed to load config for apps list refresh after experimental feature enablement: {}",
error.message
);
return;
}
};
let auth = self.auth_manager.auth().await;
if !config.features.apps_enabled_for_auth(
auth.as_ref()
.is_some_and(codex_login::CodexAuth::uses_codex_backend),
) {
return;
}
let outgoing = Arc::clone(&self.outgoing);
let environment_manager = self.thread_manager.environment_manager();
tokio::spawn(async move {
let (all_connectors_result, accessible_connectors_result) = tokio::join!(
connectors::list_all_connectors_with_options(&config, /*force_refetch*/ true),
connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
&config,
/*force_refetch*/ true,
&environment_manager,
),
);
let all_connectors = match all_connectors_result {
Ok(connectors) => connectors,
Err(err) => {
tracing::warn!(
"failed to force-refresh directory apps after experimental feature enablement: {err:#}"
);
return;
}
};
let accessible_connectors = match accessible_connectors_result {
Ok(status) => status.connectors,
Err(err) => {
tracing::warn!(
"failed to force-refresh accessible apps after experimental feature enablement: {err:#}"
);
return;
}
};
let data = connectors::with_app_enabled_state(
connectors::merge_connectors_with_accessible(
all_connectors,
accessible_connectors,
/*all_connectors_loaded*/ true,
),
&config,
);
outgoing
.send_server_notification(ServerNotification::AppListUpdated(
AppListUpdatedNotification { data },
))
.await;
});
}
async fn load_latest_config(
&self,
fallback_cwd: Option<PathBuf>,
) -> Result<codex_core::config::Config, JSONRPCErrorError> {
self.config_manager
.load_latest_config(fallback_cwd)
.await
.map_err(|err| {
internal_error(format!(
"failed to resolve feature override precedence: {err}"
))
})
}
async fn write_value(
&self,
params: ConfigValueWriteParams,
) -> Result<ConfigWriteResponse, JSONRPCErrorError> {
let pending_changes = codex_core_plugins::toggles::collect_plugin_enabled_candidates(
[(&params.key_path, &params.value)].into_iter(),
);
let response = self
.config_manager
.write_value(params)
.await
.map_err(map_error)?;
self.emit_plugin_toggle_events(pending_changes).await;
Ok(response)
}
async fn batch_write_inner(
&self,
params: ConfigBatchWriteParams,
) -> Result<ConfigWriteResponse, JSONRPCErrorError> {
let reload_user_config = params.reload_user_config;
let pending_changes = codex_core_plugins::toggles::collect_plugin_enabled_candidates(
params
.edits
.iter()
.map(|edit| (&edit.key_path, &edit.value)),
);
let response = self
.config_manager
.batch_write(params)
.await
.map_err(map_error)?;
self.emit_plugin_toggle_events(pending_changes).await;
if reload_user_config {
self.reload_user_config().await;
}
Ok(response)
}
async fn set_experimental_feature_enablement(
&self,
params: ExperimentalFeatureEnablementSetParams,
) -> Result<ExperimentalFeatureEnablementSetResponse, JSONRPCErrorError> {
let ExperimentalFeatureEnablementSetParams { enablement } = params;
for key in enablement.keys() {
if canonical_feature_for_key(key).is_some() {
if SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str()) {
continue;
}
return Err(invalid_request(format!(
"unsupported feature enablement `{key}`: currently supported features are {}",
SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.join(", ")
)));
}
let message = if let Some(feature) = feature_for_key(key) {
format!(
"invalid feature enablement `{key}`: use canonical feature key `{}`",
feature.key()
)
} else {
format!("invalid feature enablement `{key}`")
};
return Err(invalid_request(message));
}
if enablement.is_empty() {
return Ok(ExperimentalFeatureEnablementSetResponse { enablement });
}
self.config_manager
.extend_runtime_feature_enablement(
enablement
.iter()
.map(|(name, enabled)| (name.clone(), *enabled)),
)
.map_err(|_| internal_error("failed to update feature enablement"))?;
self.load_latest_config(/*fallback_cwd*/ None).await?;
self.reload_user_config().await;
Ok(ExperimentalFeatureEnablementSetResponse { enablement })
}
async fn reload_user_config(&self) {
let thread_ids = self.thread_manager.list_thread_ids().await;
for thread_id in thread_ids {
let Ok(thread) = self.thread_manager.get_thread(thread_id).await else {
continue;
};
if let Err(err) = thread.submit(Op::ReloadUserConfig).await {
tracing::warn!("failed to request user config reload: {err}");
}
}
}
async fn emit_plugin_toggle_events(
&self,
pending_changes: std::collections::BTreeMap<String, bool>,
) {
for (plugin_id, enabled) in pending_changes {
let Ok(plugin_id) = PluginId::parse(&plugin_id) else {
continue;
};
let metadata = codex_core_plugins::loader::installed_plugin_telemetry_metadata(
self.config_manager.codex_home(),
&plugin_id,
)
.await;
if enabled {
self.analytics_events_client.track_plugin_enabled(metadata);
} else {
self.analytics_events_client.track_plugin_disabled(metadata);
}
}
}
}
fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigRequirements {
ConfigRequirements {
allowed_approval_policies: requirements.allowed_approval_policies.map(|policies| {
policies
.into_iter()
.map(codex_app_server_protocol::AskForApproval::from)
.collect()
}),
allowed_approvals_reviewers: requirements.allowed_approvals_reviewers.map(|reviewers| {
reviewers
.into_iter()
.map(codex_app_server_protocol::ApprovalsReviewer::from)
.collect()
}),
allowed_sandbox_modes: requirements.allowed_sandbox_modes.map(|modes| {
modes
.into_iter()
.filter_map(map_sandbox_mode_requirement_to_api)
.collect()
}),
allowed_web_search_modes: requirements.allowed_web_search_modes.map(|modes| {
let mut normalized = modes
.into_iter()
.map(Into::into)
.collect::<Vec<WebSearchMode>>();
if !normalized.contains(&WebSearchMode::Disabled) {
normalized.push(WebSearchMode::Disabled);
}
normalized
}),
feature_requirements: requirements
.feature_requirements
.map(|requirements| requirements.entries),
hooks: requirements.hooks.map(map_hooks_requirements_to_api),
enforce_residency: requirements
.enforce_residency
.map(map_residency_requirement_to_api),
network: requirements.network.map(map_network_requirements_to_api),
}
}
fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> ManagedHooksRequirements {
let ManagedHooksRequirementsToml {
managed_dir,
windows_managed_dir,
hooks,
} = hooks;
let HookEventsToml {
pre_tool_use,
permission_request,
post_tool_use,
session_start,
user_prompt_submit,
stop,
} = hooks;
ManagedHooksRequirements {
managed_dir,
windows_managed_dir,
pre_tool_use: map_hook_matcher_groups_to_api(pre_tool_use),
permission_request: map_hook_matcher_groups_to_api(permission_request),
post_tool_use: map_hook_matcher_groups_to_api(post_tool_use),
session_start: map_hook_matcher_groups_to_api(session_start),
user_prompt_submit: map_hook_matcher_groups_to_api(user_prompt_submit),
stop: map_hook_matcher_groups_to_api(stop),
}
}
fn map_hook_matcher_groups_to_api(
groups: Vec<CoreMatcherGroup>,
) -> Vec<ConfiguredHookMatcherGroup> {
groups
.into_iter()
.map(map_hook_matcher_group_to_api)
.collect()
}
fn map_hook_matcher_group_to_api(group: CoreMatcherGroup) -> ConfiguredHookMatcherGroup {
ConfiguredHookMatcherGroup {
matcher: group.matcher,
hooks: group
.hooks
.into_iter()
.map(map_hook_handler_to_api)
.collect(),
}
}
fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHandler {
match handler {
CoreHookHandlerConfig::Command {
command,
timeout_sec,
r#async,
status_message,
} => ConfiguredHookHandler::Command {
command,
timeout_sec,
r#async,
status_message,
},
CoreHookHandlerConfig::Prompt {} => ConfiguredHookHandler::Prompt {},
CoreHookHandlerConfig::Agent {} => ConfiguredHookHandler::Agent {},
}
}
fn map_sandbox_mode_requirement_to_api(mode: CoreSandboxModeRequirement) -> Option<SandboxMode> {
match mode {
CoreSandboxModeRequirement::ReadOnly => Some(SandboxMode::ReadOnly),
CoreSandboxModeRequirement::WorkspaceWrite => Some(SandboxMode::WorkspaceWrite),
CoreSandboxModeRequirement::DangerFullAccess => Some(SandboxMode::DangerFullAccess),
CoreSandboxModeRequirement::ExternalSandbox => None,
}
}
fn map_residency_requirement_to_api(
residency: CoreResidencyRequirement,
) -> codex_app_server_protocol::ResidencyRequirement {
match residency {
CoreResidencyRequirement::Us => codex_app_server_protocol::ResidencyRequirement::Us,
}
}
fn map_network_requirements_to_api(
network: codex_config::NetworkRequirementsToml,
) -> NetworkRequirements {
let allowed_domains = network
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains);
let denied_domains = network
.domains
.as_ref()
.and_then(codex_config::NetworkDomainPermissionsToml::denied_domains);
let allow_unix_sockets = network
.unix_sockets
.as_ref()
.map(codex_config::NetworkUnixSocketPermissionsToml::allow_unix_sockets)
.filter(|entries| !entries.is_empty());
NetworkRequirements {
enabled: network.enabled,
http_port: network.http_port,
socks_port: network.socks_port,
allow_upstream_proxy: network.allow_upstream_proxy,
dangerously_allow_non_loopback_proxy: network.dangerously_allow_non_loopback_proxy,
dangerously_allow_all_unix_sockets: network.dangerously_allow_all_unix_sockets,
domains: network.domains.map(|domains| {
domains
.entries
.into_iter()
.map(|(pattern, permission)| {
(pattern, map_network_domain_permission_to_api(permission))
})
.collect()
}),
managed_allowed_domains_only: network.managed_allowed_domains_only,
allowed_domains,
denied_domains,
unix_sockets: network.unix_sockets.map(|unix_sockets| {
unix_sockets
.entries
.into_iter()
.map(|(path, permission)| {
(path, map_network_unix_socket_permission_to_api(permission))
})
.collect()
}),
allow_unix_sockets,
allow_local_binding: network.allow_local_binding,
}
}
fn map_network_domain_permission_to_api(
permission: codex_config::NetworkDomainPermissionToml,
) -> NetworkDomainPermission {
match permission {
codex_config::NetworkDomainPermissionToml::Allow => NetworkDomainPermission::Allow,
codex_config::NetworkDomainPermissionToml::Deny => NetworkDomainPermission::Deny,
}
}
fn map_network_unix_socket_permission_to_api(
permission: codex_config::NetworkUnixSocketPermissionToml,
) -> NetworkUnixSocketPermission {
match permission {
codex_config::NetworkUnixSocketPermissionToml::Allow => NetworkUnixSocketPermission::Allow,
codex_config::NetworkUnixSocketPermissionToml::None => NetworkUnixSocketPermission::None,
}
}
fn map_error(err: ConfigManagerError) -> JSONRPCErrorError {
if let Some(code) = err.write_error_code() {
return config_write_error(code, err.to_string());
}
internal_error(err.to_string())
}
fn config_write_error(code: ConfigWriteErrorCode, message: impl Into<String>) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: message.into(),
data: Some(json!({
"config_write_error_code": code,
})),
}
}
@@ -1,8 +1,16 @@
use std::fmt;
use std::future::Future;
use std::path::PathBuf;
use std::sync::Arc;
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use crate::outgoing_message::ConnectionRequestId;
use crate::outgoing_message::OutgoingMessageSender;
use async_trait::async_trait;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use codex_app_server_protocol::ClientResponsePayload;
use codex_app_server_protocol::DeviceKeyAlgorithm;
use codex_app_server_protocol::DeviceKeyCreateParams;
use codex_app_server_protocol::DeviceKeyCreateResponse;
@@ -28,19 +36,22 @@ use codex_device_key::RemoteControlClientEnrollmentAudience;
use codex_device_key::RemoteControlClientEnrollmentSignPayload;
use codex_state::DeviceKeyBindingRecord;
use codex_state::StateRuntime;
use std::fmt;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::OnceCell;
#[derive(Clone)]
pub(crate) struct DeviceKeyApi {
pub(crate) struct DeviceKeyRequestProcessor {
outgoing: Arc<OutgoingMessageSender>,
store: DeviceKeyStore,
}
impl DeviceKeyApi {
pub(crate) fn new(sqlite_home: PathBuf, default_provider: String) -> Self {
impl DeviceKeyRequestProcessor {
pub(crate) fn new(
outgoing: Arc<OutgoingMessageSender>,
sqlite_home: PathBuf,
default_provider: String,
) -> Self {
Self {
outgoing,
store: DeviceKeyStore::new(Arc::new(StateDeviceKeyBindingStore::new(
sqlite_home,
default_provider,
@@ -48,56 +59,120 @@ impl DeviceKeyApi {
}
}
pub(crate) async fn create(
pub(crate) fn create(
&self,
request_id: ConnectionRequestId,
params: DeviceKeyCreateParams,
) -> Result<DeviceKeyCreateResponse, JSONRPCErrorError> {
let info = self
.store
.create(DeviceKeyCreateRequest {
protection_policy: protection_policy_from_params(params.protection_policy),
binding: DeviceKeyBinding {
account_user_id: params.account_user_id,
client_id: params.client_id,
},
})
.await
.map_err(map_device_key_error)?;
Ok(create_response_from_info(info))
device_key_requests_allowed: bool,
) {
self.spawn_request(
request_id,
"device/key/create",
device_key_requests_allowed,
move |store| async move { create_device_key(store, params).await },
);
}
pub(crate) async fn public(
pub(crate) fn public(
&self,
request_id: ConnectionRequestId,
params: DeviceKeyPublicParams,
) -> Result<DeviceKeyPublicResponse, JSONRPCErrorError> {
let info = self
.store
.get_public(DeviceKeyGetPublicRequest {
key_id: params.key_id,
})
.await
.map_err(map_device_key_error)?;
Ok(public_response_from_info(info))
device_key_requests_allowed: bool,
) {
self.spawn_request(
request_id,
"device/key/public",
device_key_requests_allowed,
move |store| async move { public_device_key(store, params).await },
);
}
pub(crate) async fn sign(
pub(crate) fn sign(
&self,
request_id: ConnectionRequestId,
params: DeviceKeySignParams,
) -> Result<DeviceKeySignResponse, JSONRPCErrorError> {
let signature = self
.store
.sign(DeviceKeySignRequest {
key_id: params.key_id,
payload: payload_from_params(params.payload),
})
.await
.map_err(map_device_key_error)?;
Ok(DeviceKeySignResponse {
signature_der_base64: STANDARD.encode(signature.signature_der),
signed_payload_base64: STANDARD.encode(signature.signed_payload),
algorithm: algorithm_from_store(signature.algorithm),
})
device_key_requests_allowed: bool,
) {
self.spawn_request(
request_id,
"device/key/sign",
device_key_requests_allowed,
move |store| async move { sign_device_key(store, params).await },
);
}
fn spawn_request<R, F, Fut>(
&self,
request_id: ConnectionRequestId,
method: &'static str,
device_key_requests_allowed: bool,
run_request: F,
) where
R: Into<ClientResponsePayload> + Send + 'static,
F: FnOnce(DeviceKeyStore) -> Fut + Send + 'static,
Fut: Future<Output = Result<R, JSONRPCErrorError>> + Send + 'static,
{
let store = self.store.clone();
let outgoing = Arc::clone(&self.outgoing);
tokio::spawn(async move {
let result = if !device_key_requests_allowed {
Err(invalid_request(format!(
"{method} is not available over remote transports"
)))
} else {
run_request(store).await
};
outgoing.send_result(request_id, result).await;
});
}
}
async fn create_device_key(
store: DeviceKeyStore,
params: DeviceKeyCreateParams,
) -> Result<DeviceKeyCreateResponse, JSONRPCErrorError> {
let info = store
.create(DeviceKeyCreateRequest {
protection_policy: protection_policy_from_params(params.protection_policy),
binding: DeviceKeyBinding {
account_user_id: params.account_user_id,
client_id: params.client_id,
},
})
.await
.map_err(map_device_key_error)?;
Ok(create_response_from_info(info))
}
async fn public_device_key(
store: DeviceKeyStore,
params: DeviceKeyPublicParams,
) -> Result<DeviceKeyPublicResponse, JSONRPCErrorError> {
let info = store
.get_public(DeviceKeyGetPublicRequest {
key_id: params.key_id,
})
.await
.map_err(map_device_key_error)?;
Ok(public_response_from_info(info))
}
async fn sign_device_key(
store: DeviceKeyStore,
params: DeviceKeySignParams,
) -> Result<DeviceKeySignResponse, JSONRPCErrorError> {
let signature = store
.sign(DeviceKeySignRequest {
key_id: params.key_id,
payload: payload_from_params(params.payload),
})
.await
.map_err(map_device_key_error)?;
Ok(DeviceKeySignResponse {
signature_der_base64: STANDARD.encode(signature.signature_der),
signed_payload_base64: STANDARD.encode(signature.signed_payload),
algorithm: algorithm_from_store(signature.algorithm),
})
}
struct StateDeviceKeyBindingStore {
@@ -1,15 +1,22 @@
use std::sync::Arc;
use crate::config::external_agent_config::ExternalAgentConfigDetectOptions;
use crate::config::external_agent_config::ExternalAgentConfigMigrationItem as CoreMigrationItem;
use crate::config::external_agent_config::ExternalAgentConfigMigrationItemType as CoreMigrationItemType;
use crate::config::external_agent_config::ExternalAgentConfigService;
use crate::config::external_agent_config::NamedMigration as CoreNamedMigration;
use crate::config::external_agent_config::PendingPluginImport;
use crate::config_manager::ConfigManager;
use crate::error_code::internal_error;
use crate::error_code::invalid_params;
use crate::outgoing_message::ConnectionRequestId;
use crate::outgoing_message::OutgoingMessageSender;
use codex_app_server_protocol::CommandMigration;
use codex_app_server_protocol::ExternalAgentConfigDetectParams;
use codex_app_server_protocol::ExternalAgentConfigDetectResponse;
use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification;
use codex_app_server_protocol::ExternalAgentConfigImportParams;
use codex_app_server_protocol::ExternalAgentConfigImportResponse;
use codex_app_server_protocol::ExternalAgentConfigMigrationItem;
use codex_app_server_protocol::ExternalAgentConfigMigrationItemType;
use codex_app_server_protocol::HookMigration;
@@ -17,30 +24,55 @@ use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::McpServerMigration;
use codex_app_server_protocol::MigrationDetails;
use codex_app_server_protocol::PluginsMigration;
use codex_app_server_protocol::SubagentMigration;
use codex_app_server_protocol::ServerNotification;
use codex_arg0::Arg0DispatchPaths;
use codex_core::StartThreadOptions;
use codex_core::ThreadManager;
use codex_core::config::ConfigOverrides;
use codex_external_agent_sessions::ExternalAgentSessionMigration as CoreSessionMigration;
use codex_external_agent_sessions::ImportedExternalAgentSession;
use codex_external_agent_sessions::PendingSessionImport;
use codex_external_agent_sessions::prepare_validated_session_imports;
use codex_external_agent_sessions::record_imported_session;
use codex_protocol::ThreadId;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::Op;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Semaphore;
use super::ConfigRequestProcessor;
#[derive(Clone)]
pub(crate) struct ExternalAgentConfigApi {
pub(crate) struct ExternalAgentConfigRequestProcessor {
outgoing: Arc<OutgoingMessageSender>,
codex_home: PathBuf,
migration_service: ExternalAgentConfigService,
session_import_permits: Arc<Semaphore>,
thread_manager: Arc<ThreadManager>,
config_manager: ConfigManager,
config_processor: ConfigRequestProcessor,
arg0_paths: Arg0DispatchPaths,
}
impl ExternalAgentConfigApi {
pub(crate) fn new(codex_home: PathBuf) -> Self {
impl ExternalAgentConfigRequestProcessor {
pub(crate) fn new(
outgoing: Arc<OutgoingMessageSender>,
thread_manager: Arc<ThreadManager>,
config_manager: ConfigManager,
config_processor: ConfigRequestProcessor,
arg0_paths: Arg0DispatchPaths,
codex_home: PathBuf,
) -> Self {
Self {
outgoing,
migration_service: ExternalAgentConfigService::new(codex_home.clone()),
codex_home,
session_import_permits: Arc::new(Semaphore::new(1)),
thread_manager,
config_manager,
config_processor,
arg0_paths,
}
}
@@ -123,7 +155,7 @@ impl ExternalAgentConfigApi {
subagents: details
.subagents
.into_iter()
.map(|subagent| SubagentMigration {
.map(|subagent| codex_app_server_protocol::SubagentMigration {
name: subagent.name,
})
.collect(),
@@ -138,7 +170,164 @@ impl ExternalAgentConfigApi {
})
}
pub(crate) fn validate_pending_session_imports(
pub(crate) async fn import(
&self,
request_id: ConnectionRequestId,
params: ExternalAgentConfigImportParams,
) -> Result<(), JSONRPCErrorError> {
let needs_runtime_refresh = migration_items_need_runtime_refresh(&params.migration_items);
let has_migration_items = !params.migration_items.is_empty();
let has_plugin_imports = params.migration_items.iter().any(|item| {
matches!(
item.item_type,
ExternalAgentConfigMigrationItemType::Plugins
)
});
let pending_session_imports = self.validate_pending_session_imports(&params)?;
let pending_plugin_imports = self.import_external_agent_config(params).await?;
if needs_runtime_refresh {
self.config_processor.handle_config_mutation().await;
}
self.outgoing
.send_response(request_id, ExternalAgentConfigImportResponse {})
.await;
if !has_migration_items {
return Ok(());
}
let has_background_imports =
!pending_plugin_imports.is_empty() || !pending_session_imports.is_empty();
if !has_background_imports {
self.outgoing
.send_server_notification(ServerNotification::ExternalAgentConfigImportCompleted(
ExternalAgentConfigImportCompletedNotification {},
))
.await;
return Ok(());
}
let session_import_permits = Arc::clone(&self.session_import_permits);
let session_processor = self.clone();
let plugin_processor = self.clone();
let outgoing = Arc::clone(&self.outgoing);
let thread_manager = Arc::clone(&self.thread_manager);
tokio::spawn(async move {
let session_imports = async move {
if !pending_session_imports.is_empty() {
let Ok(_session_import_permit) = session_import_permits.acquire_owned().await
else {
return;
};
let pending_session_imports = session_processor
.prepare_validated_session_imports(pending_session_imports);
for pending_session_import in pending_session_imports {
match session_processor
.import_external_agent_session(pending_session_import.session)
.await
{
Ok(imported_thread_id) => {
session_processor.record_imported_session(
&pending_session_import.source_path,
imported_thread_id,
);
}
Err(error) => {
tracing::warn!(
error = %error.message,
path = %pending_session_import.source_path.display(),
"external agent session import failed"
);
}
}
}
}
};
let plugin_imports = async move {
for pending_plugin_import in pending_plugin_imports {
match plugin_processor
.complete_pending_plugin_import(pending_plugin_import)
.await
{
Ok(()) => {}
Err(error) => {
tracing::warn!(
error = %error.message,
"external agent config plugin import failed"
);
}
}
}
};
tokio::join!(session_imports, plugin_imports);
if has_plugin_imports {
thread_manager.plugins_manager().clear_cache();
thread_manager.skills_manager().clear_cache();
}
outgoing
.send_server_notification(ServerNotification::ExternalAgentConfigImportCompleted(
ExternalAgentConfigImportCompletedNotification {},
))
.await;
});
Ok(())
}
async fn import_external_agent_session(
&self,
session: ImportedExternalAgentSession,
) -> Result<ThreadId, JSONRPCErrorError> {
let ImportedExternalAgentSession {
cwd,
title,
rollout_items,
} = session;
let config = self
.config_manager
.load_with_overrides(
/*request_overrides*/ None,
ConfigOverrides {
cwd: Some(PathBuf::from(cwd.to_string_lossy().into_owned())),
codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(),
main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(),
..Default::default()
},
)
.await
.map_err(|err| {
internal_error(format!("failed to load imported session config: {err}"))
})?;
let environments = self
.thread_manager
.default_environment_selections(&config.cwd);
let imported_thread = self
.thread_manager
.start_thread_with_options(StartThreadOptions {
config,
initial_history: InitialHistory::Forked(rollout_items),
session_source: None,
dynamic_tools: Vec::new(),
persist_extended_history: false,
metrics_service_name: None,
parent_trace: None,
environments,
})
.await
.map_err(|err| internal_error(format!("failed to import session: {err}")))?;
if let Some(title) = title
&& let Some(name) = codex_core::util::normalize_thread_name(&title)
{
imported_thread
.thread
.submit(Op::SetThreadName { name })
.await
.map_err(|err| internal_error(format!("failed to name imported session: {err}")))?;
}
Ok(imported_thread.thread_id)
}
fn validate_pending_session_imports(
&self,
params: &ExternalAgentConfigImportParams,
) -> Result<Vec<CoreSessionMigration>, JSONRPCErrorError> {
@@ -176,22 +365,14 @@ impl ExternalAgentConfigApi {
Ok(selected_sessions)
}
pub(crate) fn prepare_validated_session_imports(
fn prepare_validated_session_imports(
&self,
sessions: Vec<CoreSessionMigration>,
) -> Vec<PendingSessionImport> {
prepare_validated_session_imports(&self.codex_home, sessions)
}
pub(crate) fn session_import_permits(&self) -> Arc<Semaphore> {
Arc::clone(&self.session_import_permits)
}
pub(crate) fn record_imported_session(
&self,
source_path: &std::path::Path,
imported_thread_id: ThreadId,
) {
fn record_imported_session(&self, source_path: &std::path::Path, imported_thread_id: ThreadId) {
if let Err(err) = record_imported_session(&self.codex_home, source_path, imported_thread_id)
{
tracing::warn!(
@@ -202,7 +383,7 @@ impl ExternalAgentConfigApi {
}
}
pub(crate) async fn import(
async fn import_external_agent_config(
&self,
params: ExternalAgentConfigImportParams,
) -> Result<Vec<PendingPluginImport>, JSONRPCErrorError> {
@@ -297,7 +478,7 @@ impl ExternalAgentConfigApi {
.map_err(|err| internal_error(err.to_string()))
}
pub(crate) async fn complete_pending_plugin_import(
async fn complete_pending_plugin_import(
&self,
pending_plugin_import: PendingPluginImport,
) -> Result<(), JSONRPCErrorError> {
@@ -312,9 +493,27 @@ impl ExternalAgentConfigApi {
}
}
fn migration_items_need_runtime_refresh(items: &[ExternalAgentConfigMigrationItem]) -> bool {
items.iter().any(|item| {
matches!(
item.item_type,
ExternalAgentConfigMigrationItemType::Config
| ExternalAgentConfigMigrationItemType::Skills
| ExternalAgentConfigMigrationItemType::McpServerConfig
| ExternalAgentConfigMigrationItemType::Hooks
| ExternalAgentConfigMigrationItemType::Commands
| ExternalAgentConfigMigrationItemType::Plugins
)
})
}
fn session_not_detected_error(path: &std::path::Path) -> JSONRPCErrorError {
invalid_params(format!(
"external agent session was not detected for import: {}",
path.display()
))
}
#[cfg(test)]
#[path = "external_agent_config_processor_tests.rs"]
mod external_agent_config_processor_tests;
@@ -0,0 +1,37 @@
use super::*;
fn migration_item(
item_type: ExternalAgentConfigMigrationItemType,
) -> ExternalAgentConfigMigrationItem {
ExternalAgentConfigMigrationItem {
item_type,
description: String::new(),
cwd: None,
details: None,
}
}
#[test]
fn migration_items_that_update_runtime_sources_trigger_refresh() {
assert!(migration_items_need_runtime_refresh(&[migration_item(
ExternalAgentConfigMigrationItemType::Config,
)]));
assert!(migration_items_need_runtime_refresh(&[migration_item(
ExternalAgentConfigMigrationItemType::Skills,
)]));
assert!(migration_items_need_runtime_refresh(&[migration_item(
ExternalAgentConfigMigrationItemType::McpServerConfig,
)]));
assert!(migration_items_need_runtime_refresh(&[migration_item(
ExternalAgentConfigMigrationItemType::Hooks,
)]));
assert!(migration_items_need_runtime_refresh(&[migration_item(
ExternalAgentConfigMigrationItemType::Commands,
)]));
assert!(migration_items_need_runtime_refresh(&[migration_item(
ExternalAgentConfigMigrationItemType::Plugins,
)]));
assert!(!migration_items_need_runtime_refresh(&[migration_item(
ExternalAgentConfigMigrationItemType::Sessions,
)]));
}
@@ -0,0 +1,242 @@
use super::*;
#[derive(Clone)]
pub(crate) struct FeedbackRequestProcessor {
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
config: Arc<Config>,
feedback: CodexFeedback,
log_db: Option<LogDbLayer>,
}
impl FeedbackRequestProcessor {
pub(crate) fn new(
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
config: Arc<Config>,
feedback: CodexFeedback,
log_db: Option<LogDbLayer>,
) -> Self {
Self {
auth_manager,
thread_manager,
config,
feedback,
log_db,
}
}
pub(crate) async fn feedback_upload(
&self,
params: FeedbackUploadParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.upload_feedback_response(params)
.await
.map(|response| Some(response.into()))
}
async fn upload_feedback_response(
&self,
params: FeedbackUploadParams,
) -> Result<FeedbackUploadResponse, JSONRPCErrorError> {
if !self.config.feedback_enabled {
return Err(invalid_request(
"sending feedback is disabled by configuration",
));
}
let FeedbackUploadParams {
classification,
reason,
thread_id,
include_logs,
extra_log_files,
tags,
} = params;
let conversation_id = match thread_id.as_deref() {
Some(thread_id) => match ThreadId::from_string(thread_id) {
Ok(conversation_id) => Some(conversation_id),
Err(err) => return Err(invalid_request(format!("invalid thread id: {err}"))),
},
None => None,
};
if let Some(chatgpt_user_id) = self
.auth_manager
.auth_cached()
.and_then(|auth| auth.get_chatgpt_user_id())
{
tracing::info!(target: "feedback_tags", chatgpt_user_id);
}
let snapshot = self.feedback.snapshot(conversation_id);
let thread_id = snapshot.thread_id.clone();
let (feedback_thread_ids, sqlite_feedback_logs, state_db_ctx) = if include_logs {
if let Some(log_db) = self.log_db.as_ref() {
log_db.flush().await;
}
let state_db_ctx = get_state_db(&self.config).await;
let feedback_thread_ids = match conversation_id {
Some(conversation_id) => match self
.thread_manager
.list_agent_subtree_thread_ids(conversation_id)
.await
{
Ok(thread_ids) => thread_ids,
Err(err) => {
warn!(
"failed to list feedback subtree for thread_id={conversation_id}: {err}"
);
let mut thread_ids = vec![conversation_id];
if let Some(state_db_ctx) = state_db_ctx.as_ref() {
for status in [
codex_state::DirectionalThreadSpawnEdgeStatus::Open,
codex_state::DirectionalThreadSpawnEdgeStatus::Closed,
] {
match state_db_ctx
.list_thread_spawn_descendants_with_status(
conversation_id,
status,
)
.await
{
Ok(descendant_ids) => thread_ids.extend(descendant_ids),
Err(err) => warn!(
"failed to list persisted feedback subtree for thread_id={conversation_id}: {err}"
),
}
}
}
thread_ids
}
},
None => Vec::new(),
};
let sqlite_feedback_logs = if let Some(state_db_ctx) = state_db_ctx.as_ref()
&& !feedback_thread_ids.is_empty()
{
let thread_id_texts = feedback_thread_ids
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>();
let thread_id_refs = thread_id_texts
.iter()
.map(String::as_str)
.collect::<Vec<_>>();
match state_db_ctx
.query_feedback_logs_for_threads(&thread_id_refs)
.await
{
Ok(logs) if logs.is_empty() => None,
Ok(logs) => Some(logs),
Err(err) => {
let thread_ids = thread_id_texts.join(", ");
warn!(
"failed to query feedback logs from sqlite for thread_ids=[{thread_ids}]: {err}"
);
None
}
}
} else {
None
};
(feedback_thread_ids, sqlite_feedback_logs, state_db_ctx)
} else {
(Vec::new(), None, None)
};
let mut attachment_paths = Vec::new();
let mut seen_attachment_paths = HashSet::new();
if include_logs {
for feedback_thread_id in &feedback_thread_ids {
let Some(rollout_path) = self
.resolve_rollout_path(*feedback_thread_id, state_db_ctx.as_ref())
.await
else {
continue;
};
if seen_attachment_paths.insert(rollout_path.clone()) {
attachment_paths.push(FeedbackAttachmentPath {
path: rollout_path,
attachment_filename_override: None,
});
}
}
if let Some(conversation_id) = conversation_id
&& let Ok(conversation) = self.thread_manager.get_thread(conversation_id).await
&& let Some(guardian_rollout_path) =
conversation.guardian_trunk_rollout_path().await
&& seen_attachment_paths.insert(guardian_rollout_path.clone())
{
attachment_paths.push(FeedbackAttachmentPath {
path: guardian_rollout_path,
attachment_filename_override: Some(auto_review_rollout_filename(
conversation_id,
)),
});
}
}
if let Some(extra_log_files) = extra_log_files {
for extra_log_file in extra_log_files {
if seen_attachment_paths.insert(extra_log_file.clone()) {
attachment_paths.push(FeedbackAttachmentPath {
path: extra_log_file,
attachment_filename_override: None,
});
}
}
}
let session_source = self.thread_manager.session_source();
let upload_result = tokio::task::spawn_blocking(move || {
snapshot.upload_feedback(FeedbackUploadOptions {
classification: &classification,
reason: reason.as_deref(),
tags: tags.as_ref(),
include_logs,
extra_attachment_paths: &attachment_paths,
session_source: Some(session_source),
logs_override: sqlite_feedback_logs,
})
})
.await;
let upload_result = match upload_result {
Ok(result) => result,
Err(join_err) => {
return Err(internal_error(format!(
"failed to upload feedback: {join_err}"
)));
}
};
upload_result.map_err(|err| internal_error(format!("failed to upload feedback: {err}")))?;
Ok(FeedbackUploadResponse { thread_id })
}
async fn resolve_rollout_path(
&self,
conversation_id: ThreadId,
state_db_ctx: Option<&StateDbHandle>,
) -> Option<PathBuf> {
if let Ok(conversation) = self.thread_manager.get_thread(conversation_id).await
&& let Some(rollout_path) = conversation.rollout_path()
{
return Some(rollout_path);
}
let state_db_ctx = state_db_ctx?;
state_db_ctx
.find_rollout_path_by_id(conversation_id, /*archived_only*/ None)
.await
.unwrap_or_else(|err| {
warn!("failed to resolve rollout path for thread_id={conversation_id}: {err}");
None
})
}
}
fn auto_review_rollout_filename(thread_id: ThreadId) -> String {
format!("auto-review-rollout-{thread_id}.jsonl")
}
@@ -1,5 +1,7 @@
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use crate::fs_watch::FsWatchManager;
use crate::outgoing_message::ConnectionId;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use codex_app_server_protocol::FsCopyParams;
@@ -15,6 +17,10 @@ use codex_app_server_protocol::FsReadFileParams;
use codex_app_server_protocol::FsReadFileResponse;
use codex_app_server_protocol::FsRemoveParams;
use codex_app_server_protocol::FsRemoveResponse;
use codex_app_server_protocol::FsUnwatchParams;
use codex_app_server_protocol::FsUnwatchResponse;
use codex_app_server_protocol::FsWatchParams;
use codex_app_server_protocol::FsWatchResponse;
use codex_app_server_protocol::FsWriteFileParams;
use codex_app_server_protocol::FsWriteFileResponse;
use codex_app_server_protocol::JSONRPCErrorError;
@@ -26,13 +32,24 @@ use std::io;
use std::sync::Arc;
#[derive(Clone)]
pub(crate) struct FsApi {
pub(crate) struct FsRequestProcessor {
file_system: Arc<dyn ExecutorFileSystem>,
fs_watch_manager: FsWatchManager,
}
impl FsApi {
pub(crate) fn new(file_system: Arc<dyn ExecutorFileSystem>) -> Self {
Self { file_system }
impl FsRequestProcessor {
pub(crate) fn new(
file_system: Arc<dyn ExecutorFileSystem>,
fs_watch_manager: FsWatchManager,
) -> Self {
Self {
file_system,
fs_watch_manager,
}
}
pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) {
self.fs_watch_manager.connection_closed(connection_id).await;
}
pub(crate) async fn read_file(
@@ -156,9 +173,25 @@ impl FsApi {
.map_err(map_fs_error)?;
Ok(FsCopyResponse {})
}
pub(crate) async fn watch(
&self,
connection_id: ConnectionId,
params: FsWatchParams,
) -> Result<FsWatchResponse, JSONRPCErrorError> {
self.fs_watch_manager.watch(connection_id, params).await
}
pub(crate) async fn unwatch(
&self,
connection_id: ConnectionId,
params: FsUnwatchParams,
) -> Result<FsUnwatchResponse, JSONRPCErrorError> {
self.fs_watch_manager.unwatch(connection_id, params).await
}
}
pub(crate) fn map_fs_error(err: io::Error) -> JSONRPCErrorError {
fn map_fs_error(err: io::Error) -> JSONRPCErrorError {
if err.kind() == io::ErrorKind::InvalidInput {
invalid_request(err.to_string())
} else {
@@ -0,0 +1,36 @@
use super::*;
#[derive(Clone)]
pub(crate) struct GitRequestProcessor;
impl GitRequestProcessor {
pub(crate) fn new() -> Self {
Self
}
pub(crate) async fn git_diff_to_remote(
&self,
params: GitDiffToRemoteParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.git_diff_to_origin(params.cwd)
.await
.map(|response| Some(response.into()))
}
async fn git_diff_to_origin(
&self,
cwd: PathBuf,
) -> Result<GitDiffToRemoteResponse, JSONRPCErrorError> {
git_diff_to_remote(&cwd)
.await
.map(|value| GitDiffToRemoteResponse {
sha: value.sha,
diff: value.diff,
})
.ok_or_else(|| {
invalid_request(format!(
"failed to compute git diff to remote for cwd: {cwd:?}"
))
})
}
}
@@ -0,0 +1,184 @@
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use axum::http::HeaderValue;
use codex_analytics::AppServerRpcTransport;
use codex_login::default_client::SetOriginatorError;
use codex_login::default_client::USER_AGENT_SUFFIX;
use codex_login::default_client::get_codex_user_agent;
use codex_login::default_client::set_default_client_residency_requirement;
use codex_login::default_client::set_default_originator;
use super::*;
use crate::message_processor::ConnectionSessionState;
use crate::message_processor::InitializedConnectionSessionState;
#[derive(Clone)]
pub(crate) struct InitializeRequestProcessor {
outgoing: Arc<OutgoingMessageSender>,
analytics_events_client: AnalyticsEventsClient,
config: Arc<Config>,
config_warnings: Arc<Vec<ConfigWarningNotification>>,
rpc_transport: AppServerRpcTransport,
}
impl InitializeRequestProcessor {
pub(crate) fn new(
outgoing: Arc<OutgoingMessageSender>,
analytics_events_client: AnalyticsEventsClient,
config: Arc<Config>,
config_warnings: Vec<ConfigWarningNotification>,
rpc_transport: AppServerRpcTransport,
) -> Self {
Self {
outgoing,
analytics_events_client,
config,
config_warnings: Arc::new(config_warnings),
rpc_transport,
}
}
pub(crate) async fn initialize(
&self,
connection_id: ConnectionId,
request_id: RequestId,
params: InitializeParams,
session: &ConnectionSessionState,
// `Some(...)` means the caller wants initialize to immediately mark the
// connection outbound-ready. Websocket JSON-RPC calls pass `None` so
// lib.rs can deliver connection-scoped initialize notifications first.
outbound_initialized: Option<&AtomicBool>,
) -> Result<bool, JSONRPCErrorError> {
let connection_request_id = ConnectionRequestId {
connection_id,
request_id,
};
if session.initialized() {
return Err(invalid_request("Already initialized"));
}
// TODO(maxj): Revisit capability scoping for `experimental_api_enabled`.
// Current behavior is per-connection. Reviewer feedback notes this can
// create odd cross-client behavior (for example dynamic tool calls on a
// shared thread when another connected client did not opt into
// experimental API). Proposed direction is instance-global first-write-wins
// with initialize-time mismatch rejection.
let analytics_initialize_params = params.clone();
let (experimental_api_enabled, opt_out_notification_methods) = match params.capabilities {
Some(capabilities) => (
capabilities.experimental_api,
capabilities
.opt_out_notification_methods
.unwrap_or_default(),
),
None => (false, Vec::new()),
};
let ClientInfo {
name,
title: _title,
version,
} = params.client_info;
// Validate before committing; set_default_originator validates while
// mutating process-global metadata.
if HeaderValue::from_str(&name).is_err() {
return Err(invalid_request(format!(
"Invalid clientInfo.name: '{name}'. Must be a valid HTTP header value."
)));
}
let originator = name.clone();
let user_agent_suffix = format!("{name}; {version}");
let codex_home = self.config.codex_home.clone();
if session
.initialize(InitializedConnectionSessionState {
experimental_api_enabled,
opted_out_notification_methods: opt_out_notification_methods.into_iter().collect(),
app_server_client_name: name.clone(),
client_version: version,
})
.is_err()
{
return Err(invalid_request("Already initialized"));
}
// Only the request that wins session initialization may mutate
// process-global client metadata.
if let Err(error) = set_default_originator(originator.clone()) {
match error {
SetOriginatorError::InvalidHeaderValue => {
tracing::warn!(
client_info_name = %name,
"validated clientInfo.name was rejected while setting originator"
);
}
SetOriginatorError::AlreadyInitialized => {
// No-op. This is expected to happen if the originator is already set via env var.
// TODO(owen): Once we remove support for CODEX_INTERNAL_ORIGINATOR_OVERRIDE,
// this will be an unexpected state and we can return a JSON-RPC error indicating
// internal server error.
}
}
}
self.analytics_events_client.track_initialize(
connection_id.0,
analytics_initialize_params,
originator,
self.rpc_transport,
);
set_default_client_residency_requirement(self.config.enforce_residency.value());
if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() {
*suffix = Some(user_agent_suffix);
}
let user_agent = get_codex_user_agent();
let response = InitializeResponse {
user_agent,
codex_home,
platform_family: std::env::consts::FAMILY.to_string(),
platform_os: std::env::consts::OS.to_string(),
};
self.outgoing
.send_response(connection_request_id, response)
.await;
if let Some(outbound_initialized) = outbound_initialized {
outbound_initialized.store(true, Ordering::Release);
return Ok(true);
}
Ok(false)
}
pub(crate) async fn send_initialize_notifications_to_connection(
&self,
connection_id: ConnectionId,
) {
for notification in self.config_warnings.iter().cloned() {
self.outgoing
.send_server_notification_to_connections(
&[connection_id],
ServerNotification::ConfigWarning(notification),
)
.await;
}
}
pub(crate) async fn send_initialize_notifications(&self) {
for notification in self.config_warnings.iter().cloned() {
self.outgoing
.send_server_notification(ServerNotification::ConfigWarning(notification))
.await;
}
}
pub(crate) fn track_initialized_request(
&self,
connection_id: ConnectionId,
request_id: RequestId,
request: &ClientRequest,
) {
self.analytics_events_client
.track_request(connection_id.0, request_id, request);
}
}
@@ -0,0 +1,141 @@
use super::*;
#[derive(Clone)]
pub(crate) struct MarketplaceRequestProcessor {
config: Arc<Config>,
config_manager: ConfigManager,
thread_manager: Arc<ThreadManager>,
}
impl MarketplaceRequestProcessor {
pub(crate) fn new(
config: Arc<Config>,
config_manager: ConfigManager,
thread_manager: Arc<ThreadManager>,
) -> Self {
Self {
config,
config_manager,
thread_manager,
}
}
pub(crate) async fn marketplace_add(
&self,
params: MarketplaceAddParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.marketplace_add_inner(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn marketplace_remove(
&self,
params: MarketplaceRemoveParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.marketplace_remove_inner(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn marketplace_upgrade(
&self,
params: MarketplaceUpgradeParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.marketplace_upgrade_response_inner(params)
.await
.map(|response| Some(response.into()))
}
async fn marketplace_remove_inner(
&self,
params: MarketplaceRemoveParams,
) -> Result<MarketplaceRemoveResponse, JSONRPCErrorError> {
remove_marketplace(
self.config.codex_home.to_path_buf(),
CoreMarketplaceRemoveRequest {
marketplace_name: params.marketplace_name,
},
)
.await
.map(|outcome| MarketplaceRemoveResponse {
marketplace_name: outcome.marketplace_name,
installed_root: outcome.removed_installed_root,
})
.map_err(|err| match err {
MarketplaceRemoveError::InvalidRequest(message) => invalid_request(message),
MarketplaceRemoveError::Internal(message) => internal_error(message),
})
}
async fn marketplace_upgrade_response_inner(
&self,
params: MarketplaceUpgradeParams,
) -> Result<MarketplaceUpgradeResponse, JSONRPCErrorError> {
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
let plugins_manager = self.thread_manager.plugins_manager();
let MarketplaceUpgradeParams { marketplace_name } = params;
let plugins_input = config.plugins_config_input();
let outcome = tokio::task::spawn_blocking(move || {
plugins_manager.upgrade_configured_marketplaces_for_config(
&plugins_input,
marketplace_name.as_deref(),
)
})
.await
.map_err(|err| internal_error(format!("failed to upgrade marketplaces: {err}")))?
.map_err(invalid_request)?;
Ok(MarketplaceUpgradeResponse {
selected_marketplaces: outcome.selected_marketplaces,
upgraded_roots: outcome.upgraded_roots,
errors: outcome
.errors
.into_iter()
.map(|err| MarketplaceUpgradeErrorInfo {
marketplace_name: err.marketplace_name,
message: err.message,
})
.collect(),
})
}
async fn marketplace_add_inner(
&self,
params: MarketplaceAddParams,
) -> Result<MarketplaceAddResponse, JSONRPCErrorError> {
add_marketplace_to_codex_home(
self.config.codex_home.to_path_buf(),
MarketplaceAddRequest {
source: params.source,
ref_name: params.ref_name,
sparse_paths: params.sparse_paths.unwrap_or_default(),
},
)
.await
.map(|outcome| MarketplaceAddResponse {
marketplace_name: outcome.marketplace_name,
installed_root: outcome.installed_root,
already_added: outcome.already_added,
})
.map_err(|err| match err {
MarketplaceAddError::InvalidRequest(message) => invalid_request(message),
MarketplaceAddError::Internal(message) => internal_error(message),
})
}
async fn load_latest_config(
&self,
fallback_cwd: Option<PathBuf>,
) -> Result<Config, JSONRPCErrorError> {
self.config_manager
.load_latest_config(fallback_cwd)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to reload config: {err}"),
data: None,
})
}
}
@@ -0,0 +1,513 @@
use super::*;
const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId";
#[derive(Clone)]
pub(crate) struct McpRequestProcessor {
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
outgoing: Arc<OutgoingMessageSender>,
config_manager: ConfigManager,
}
impl McpRequestProcessor {
pub(crate) fn new(
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
outgoing: Arc<OutgoingMessageSender>,
config_manager: ConfigManager,
) -> Self {
Self {
auth_manager,
thread_manager,
outgoing,
config_manager,
}
}
pub(crate) async fn mcp_server_oauth_login(
&self,
params: McpServerOauthLoginParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.mcp_server_oauth_login_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn mcp_server_refresh(
&self,
params: Option<()>,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.mcp_server_refresh_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn mcp_server_status_list(
&self,
request_id: &ConnectionRequestId,
params: ListMcpServerStatusParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.list_mcp_server_status(request_id, params)
.await
.map(|()| None)
}
pub(crate) async fn mcp_resource_read(
&self,
request_id: &ConnectionRequestId,
params: McpResourceReadParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.read_mcp_resource(request_id, params)
.await
.map(|()| None)
}
pub(crate) async fn mcp_server_tool_call(
&self,
request_id: &ConnectionRequestId,
params: McpServerToolCallParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.call_mcp_server_tool(request_id, params)
.await
.map(|()| None)
}
async fn mcp_server_refresh_response(
&self,
_params: Option<()>,
) -> Result<McpServerRefreshResponse, JSONRPCErrorError> {
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
Self::queue_mcp_server_refresh_for_config(&self.thread_manager, &config).await?;
Ok(McpServerRefreshResponse {})
}
async fn load_latest_config(
&self,
fallback_cwd: Option<PathBuf>,
) -> Result<Config, JSONRPCErrorError> {
self.config_manager
.load_latest_config(fallback_cwd)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to reload config: {err}"),
data: None,
})
}
async fn load_thread(
&self,
thread_id: &str,
) -> Result<(ThreadId, Arc<CodexThread>), JSONRPCErrorError> {
let thread_id = ThreadId::from_string(thread_id).map_err(|err| JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("invalid thread id: {err}"),
data: None,
})?;
let thread = self
.thread_manager
.get_thread(thread_id)
.await
.map_err(|_| JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("thread not found: {thread_id}"),
data: None,
})?;
Ok((thread_id, thread))
}
pub(super) async fn queue_mcp_server_refresh_for_config(
thread_manager: &Arc<ThreadManager>,
config: &Config,
) -> Result<(), JSONRPCErrorError> {
let configured_servers = thread_manager
.mcp_manager()
.configured_servers(config)
.await;
let mcp_servers = match serde_json::to_value(configured_servers) {
Ok(value) => value,
Err(err) => {
return Err(JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to serialize MCP servers: {err}"),
data: None,
});
}
};
let mcp_oauth_credentials_store_mode =
match serde_json::to_value(config.mcp_oauth_credentials_store_mode) {
Ok(value) => value,
Err(err) => {
return Err(JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!(
"failed to serialize MCP OAuth credentials store mode: {err}"
),
data: None,
});
}
};
let refresh_config = McpServerRefreshConfig {
mcp_servers,
mcp_oauth_credentials_store_mode,
};
// Refresh requests are queued per thread; each thread rebuilds MCP connections on its next
// active turn to avoid work for threads that never resume.
thread_manager.refresh_mcp_servers(refresh_config).await;
Ok(())
}
async fn mcp_server_oauth_login_response(
&self,
params: McpServerOauthLoginParams,
) -> Result<McpServerOauthLoginResponse, JSONRPCErrorError> {
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
let McpServerOauthLoginParams {
name,
scopes,
timeout_secs,
} = params;
let configured_servers = self
.thread_manager
.mcp_manager()
.configured_servers(&config)
.await;
let Some(server) = configured_servers.get(&name) else {
return Err(invalid_request(format!(
"No MCP server named '{name}' found."
)));
};
let (url, http_headers, env_http_headers) = match &server.transport {
McpServerTransportConfig::StreamableHttp {
url,
http_headers,
env_http_headers,
..
} => (url.clone(), http_headers.clone(), env_http_headers.clone()),
_ => {
return Err(invalid_request(
"OAuth login is only supported for streamable HTTP servers.",
));
}
};
let discovered_scopes = if scopes.is_none() && server.scopes.is_none() {
discover_supported_scopes(&server.transport).await
} else {
None
};
let resolved_scopes =
resolve_oauth_scopes(scopes, server.scopes.clone(), discovered_scopes);
let handle = perform_oauth_login_return_url(
&name,
&url,
config.mcp_oauth_credentials_store_mode,
http_headers,
env_http_headers,
&resolved_scopes.scopes,
server.oauth_resource.as_deref(),
timeout_secs,
config.mcp_oauth_callback_port,
config.mcp_oauth_callback_url.as_deref(),
)
.await
.map_err(|err| internal_error(format!("failed to login to MCP server '{name}': {err}")))?;
let authorization_url = handle.authorization_url().to_string();
let notification_name = name.clone();
let outgoing = Arc::clone(&self.outgoing);
tokio::spawn(async move {
let (success, error) = match handle.wait().await {
Ok(()) => (true, None),
Err(err) => (false, Some(err.to_string())),
};
let notification = ServerNotification::McpServerOauthLoginCompleted(
McpServerOauthLoginCompletedNotification {
name: notification_name,
success,
error,
},
);
outgoing.send_server_notification(notification).await;
});
Ok(McpServerOauthLoginResponse { authorization_url })
}
async fn list_mcp_server_status(
&self,
request_id: &ConnectionRequestId,
params: ListMcpServerStatusParams,
) -> Result<(), JSONRPCErrorError> {
let request = request_id.clone();
let outgoing = Arc::clone(&self.outgoing);
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
let mcp_config = config
.to_mcp_config(self.thread_manager.plugins_manager().as_ref())
.await;
let auth = self.auth_manager.auth().await;
let environment_manager = self.thread_manager.environment_manager();
let runtime_environment = match environment_manager.default_environment() {
Some(environment) => {
// Status listing has no turn cwd. This fallback is used only
// by executor-backed stdio MCPs whose config omits `cwd`.
McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf())
}
None => McpRuntimeEnvironment::new(
environment_manager.local_environment(),
config.cwd.to_path_buf(),
),
};
tokio::spawn(async move {
Self::list_mcp_server_status_task(
outgoing,
request,
params,
config,
mcp_config,
auth,
runtime_environment,
)
.await;
});
Ok(())
}
async fn list_mcp_server_status_task(
outgoing: Arc<OutgoingMessageSender>,
request_id: ConnectionRequestId,
params: ListMcpServerStatusParams,
config: Config,
mcp_config: codex_mcp::McpConfig,
auth: Option<CodexAuth>,
runtime_environment: McpRuntimeEnvironment,
) {
let result = Self::list_mcp_server_status_response(
request_id.request_id.to_string(),
params,
config,
mcp_config,
auth,
runtime_environment,
)
.await;
outgoing.send_result(request_id, result).await;
}
async fn list_mcp_server_status_response(
request_id: String,
params: ListMcpServerStatusParams,
config: Config,
mcp_config: codex_mcp::McpConfig,
auth: Option<CodexAuth>,
runtime_environment: McpRuntimeEnvironment,
) -> Result<ListMcpServerStatusResponse, JSONRPCErrorError> {
let detail = match params.detail.unwrap_or(McpServerStatusDetail::Full) {
McpServerStatusDetail::Full => McpSnapshotDetail::Full,
McpServerStatusDetail::ToolsAndAuthOnly => McpSnapshotDetail::ToolsAndAuthOnly,
};
let snapshot = collect_mcp_server_status_snapshot_with_detail(
&mcp_config,
auth.as_ref(),
request_id,
runtime_environment,
detail,
)
.await;
let effective_servers = effective_mcp_servers(&mcp_config, auth.as_ref());
let McpServerStatusSnapshot {
tools_by_server,
resources,
resource_templates,
auth_statuses,
} = snapshot;
let mut server_names: Vec<String> = config
.mcp_servers
.keys()
.cloned()
// Include built-in/plugin MCP servers that are present in the
// effective runtime config even when they are not user-declared in
// `config.mcp_servers`.
.chain(effective_servers.keys().cloned())
.chain(auth_statuses.keys().cloned())
.chain(resources.keys().cloned())
.chain(resource_templates.keys().cloned())
.collect();
server_names.sort();
server_names.dedup();
let total = server_names.len();
let limit = params.limit.unwrap_or(total as u32).max(1) as usize;
let effective_limit = limit.min(total);
let start = match params.cursor {
Some(cursor) => match cursor.parse::<usize>() {
Ok(idx) => idx,
Err(_) => return Err(invalid_request(format!("invalid cursor: {cursor}"))),
},
None => 0,
};
if start > total {
return Err(invalid_request(format!(
"cursor {start} exceeds total MCP servers {total}"
)));
}
let end = start.saturating_add(effective_limit).min(total);
let data: Vec<McpServerStatus> = server_names[start..end]
.iter()
.map(|name| McpServerStatus {
name: name.clone(),
tools: tools_by_server.get(name).cloned().unwrap_or_default(),
resources: resources.get(name).cloned().unwrap_or_default(),
resource_templates: resource_templates.get(name).cloned().unwrap_or_default(),
auth_status: auth_statuses
.get(name)
.cloned()
.unwrap_or(CoreMcpAuthStatus::Unsupported)
.into(),
})
.collect();
let next_cursor = if end < total {
Some(end.to_string())
} else {
None
};
Ok(ListMcpServerStatusResponse { data, next_cursor })
}
async fn read_mcp_resource(
&self,
request_id: &ConnectionRequestId,
params: McpResourceReadParams,
) -> Result<(), JSONRPCErrorError> {
let outgoing = Arc::clone(&self.outgoing);
let McpResourceReadParams {
thread_id,
server,
uri,
} = params;
if let Some(thread_id) = thread_id {
let (_, thread) = self.load_thread(&thread_id).await?;
let request_id = request_id.clone();
tokio::spawn(async move {
let result = thread.read_mcp_resource(&server, &uri).await;
Self::send_mcp_resource_read_response(outgoing, request_id, result).await;
});
return Ok(());
}
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
let mcp_config = config
.to_mcp_config(self.thread_manager.plugins_manager().as_ref())
.await;
let auth = self.auth_manager.auth().await;
let runtime_environment = {
let environment_manager = self.thread_manager.environment_manager();
let environment = environment_manager
.default_environment()
.unwrap_or_else(|| environment_manager.local_environment());
// Resource reads without a thread have no turn cwd. This fallback
// is used only by executor-backed stdio MCPs whose config omits `cwd`.
McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf())
};
let request_id = request_id.clone();
tokio::spawn(async move {
let result = read_mcp_resource_without_thread(
&mcp_config,
auth.as_ref(),
runtime_environment,
&server,
&uri,
)
.await
.and_then(|result| serde_json::to_value(result).map_err(anyhow::Error::from));
Self::send_mcp_resource_read_response(outgoing, request_id, result).await;
});
Ok(())
}
async fn send_mcp_resource_read_response(
outgoing: Arc<OutgoingMessageSender>,
request_id: ConnectionRequestId,
result: anyhow::Result<serde_json::Value>,
) {
let result = result
.map_err(|error| internal_error(format!("{error:#}")))
.and_then(|result| {
serde_json::from_value::<McpResourceReadResponse>(result).map_err(|error| {
internal_error(format!(
"failed to deserialize MCP resource read response: {error}"
))
})
});
outgoing.send_result(request_id, result).await;
}
async fn call_mcp_server_tool(
&self,
request_id: &ConnectionRequestId,
params: McpServerToolCallParams,
) -> Result<(), JSONRPCErrorError> {
let outgoing = Arc::clone(&self.outgoing);
let thread_id = params.thread_id.clone();
let (_, thread) = self.load_thread(&thread_id).await?;
let meta = with_mcp_tool_call_thread_id_meta(params.meta, &thread_id);
let request_id = request_id.clone();
tokio::spawn(async move {
let result = thread
.call_mcp_tool(&params.server, &params.tool, params.arguments, meta)
.await
.map(McpServerToolCallResponse::from)
.map_err(|error| internal_error(format!("{error:#}")));
outgoing.send_result(request_id, result).await;
});
Ok(())
}
}
fn with_mcp_tool_call_thread_id_meta(
meta: Option<serde_json::Value>,
thread_id: &str,
) -> Option<serde_json::Value> {
match meta {
Some(serde_json::Value::Object(mut map)) => {
map.insert(
MCP_TOOL_THREAD_ID_META_KEY.to_string(),
serde_json::Value::String(thread_id.to_string()),
);
Some(serde_json::Value::Object(map))
}
None => {
let mut map = serde_json::Map::new();
map.insert(
MCP_TOOL_THREAD_ID_META_KEY.to_string(),
serde_json::Value::String(thread_id.to_string()),
);
Some(serde_json::Value::Object(map))
}
other => other,
}
}
@@ -3,17 +3,249 @@ use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use codex_app_server_protocol::PluginAvailability;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_config::types::McpServerConfig;
use codex_core_plugins::remote::is_valid_remote_plugin_id;
use codex_core_plugins::remote::validate_remote_plugin_id;
use codex_mcp::McpOAuthLoginSupport;
use codex_mcp::oauth_login_support;
use codex_mcp::should_retry_without_scopes;
use codex_rmcp_client::perform_oauth_login_silent;
impl CodexMessageProcessor {
pub(super) async fn plugin_list(
#[derive(Clone)]
pub(crate) struct PluginRequestProcessor {
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
outgoing: Arc<OutgoingMessageSender>,
analytics_events_client: AnalyticsEventsClient,
config_manager: ConfigManager,
workspace_settings_cache: Arc<workspace_settings::WorkspaceSettingsCache>,
}
fn plugin_skills_to_info(
skills: &[codex_core::skills::SkillMetadata],
disabled_skill_paths: &HashSet<AbsolutePathBuf>,
) -> Vec<SkillSummary> {
skills
.iter()
.map(|skill| SkillSummary {
name: skill.name.clone(),
description: skill.description.clone(),
short_description: skill.short_description.clone(),
interface: skill.interface.clone().map(|interface| {
codex_app_server_protocol::SkillInterface {
display_name: interface.display_name,
short_description: interface.short_description,
icon_small: interface.icon_small,
icon_large: interface.icon_large,
brand_color: interface.brand_color,
default_prompt: interface.default_prompt,
}
}),
path: Some(skill.path_to_skills_md.clone()),
enabled: !disabled_skill_paths.contains(&skill.path_to_skills_md),
})
.collect()
}
fn local_plugin_interface_to_info(interface: PluginManifestInterface) -> PluginInterface {
PluginInterface {
display_name: interface.display_name,
short_description: interface.short_description,
long_description: interface.long_description,
developer_name: interface.developer_name,
category: interface.category,
capabilities: interface.capabilities,
website_url: interface.website_url,
privacy_policy_url: interface.privacy_policy_url,
terms_of_service_url: interface.terms_of_service_url,
default_prompt: interface.default_prompt,
brand_color: interface.brand_color,
composer_icon: interface.composer_icon,
composer_icon_url: None,
logo: interface.logo,
logo_url: None,
screenshots: interface.screenshots,
screenshot_urls: Vec::new(),
}
}
fn marketplace_plugin_source_to_info(source: MarketplacePluginSource) -> PluginSource {
match source {
MarketplacePluginSource::Local { path } => PluginSource::Local { path },
MarketplacePluginSource::Git {
url,
path,
ref_name,
sha,
} => PluginSource::Git {
url,
path,
ref_name,
sha,
},
}
}
impl PluginRequestProcessor {
pub(crate) fn new(
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
outgoing: Arc<OutgoingMessageSender>,
analytics_events_client: AnalyticsEventsClient,
config_manager: ConfigManager,
workspace_settings_cache: Arc<workspace_settings::WorkspaceSettingsCache>,
) -> Self {
Self {
auth_manager,
thread_manager,
outgoing,
analytics_events_client,
config_manager,
workspace_settings_cache,
}
}
pub(crate) async fn plugin_list(
&self,
request_id: ConnectionRequestId,
params: PluginListParams,
) {
let result = self.plugin_list_response(params).await;
self.outgoing.send_result(request_id, result).await;
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.plugin_list_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn plugin_read(
&self,
params: PluginReadParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.plugin_read_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn plugin_skill_read(
&self,
params: PluginSkillReadParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.plugin_skill_read_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn plugin_share_save(
&self,
params: PluginShareSaveParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.plugin_share_save_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn plugin_share_list(
&self,
params: PluginShareListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.plugin_share_list_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn plugin_share_delete(
&self,
params: PluginShareDeleteParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.plugin_share_delete_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn plugin_install(
&self,
params: PluginInstallParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.plugin_install_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn plugin_uninstall(
&self,
params: PluginUninstallParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.plugin_uninstall_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) fn effective_plugins_changed_callback(
&self,
config: Config,
) -> Arc<dyn Fn() + Send + Sync> {
let thread_manager = Arc::clone(&self.thread_manager);
Arc::new(move || {
Self::spawn_effective_plugins_changed_task(Arc::clone(&thread_manager), config.clone());
})
}
fn on_effective_plugins_changed(&self, config: Config) {
Self::spawn_effective_plugins_changed_task(Arc::clone(&self.thread_manager), config);
}
fn spawn_effective_plugins_changed_task(thread_manager: Arc<ThreadManager>, config: Config) {
tokio::spawn(async move {
thread_manager.plugins_manager().clear_cache();
thread_manager.skills_manager().clear_cache();
if thread_manager.list_thread_ids().await.is_empty() {
return;
}
if let Err(err) =
McpRequestProcessor::queue_mcp_server_refresh_for_config(&thread_manager, &config)
.await
{
warn!("failed to queue MCP refresh after effective plugins changed: {err:?}");
}
});
}
fn clear_plugin_related_caches(&self) {
self.thread_manager.plugins_manager().clear_cache();
self.thread_manager.skills_manager().clear_cache();
}
async fn load_latest_config(
&self,
fallback_cwd: Option<PathBuf>,
) -> Result<Config, JSONRPCErrorError> {
self.config_manager
.load_latest_config(fallback_cwd)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to reload config: {err}"),
data: None,
})
}
async fn workspace_codex_plugins_enabled(
&self,
config: &Config,
auth: Option<&CodexAuth>,
) -> bool {
match workspace_settings::codex_plugins_enabled_for_workspace(
config,
auth,
Some(&self.workspace_settings_cache),
)
.await
{
Ok(enabled) => enabled,
Err(err) => {
warn!(
"failed to fetch workspace Codex plugins setting; allowing Codex plugins: {err:#}"
);
true
}
}
}
async fn plugin_list_response(
@@ -173,15 +405,6 @@ impl CodexMessageProcessor {
})
}
pub(super) async fn plugin_read(
&self,
request_id: ConnectionRequestId,
params: PluginReadParams,
) {
let result = self.plugin_read_response(params).await;
self.outgoing.send_result(request_id, result).await;
}
async fn plugin_read_response(
&self,
params: PluginReadParams,
@@ -219,12 +442,9 @@ impl CodexMessageProcessor {
.await
.map_err(|err| Self::marketplace_error(err, "read plugin details"))?;
let environment_manager = self.thread_manager.environment_manager();
let app_summaries = plugin_app_helpers::load_plugin_app_summaries(
&config,
&outcome.plugin.apps,
&environment_manager,
)
.await;
let app_summaries =
load_plugin_app_summaries(&config, &outcome.plugin.apps, &environment_manager)
.await;
let visible_skills = outcome
.plugin
.skills
@@ -289,12 +509,8 @@ impl CodexMessageProcessor {
.map(codex_plugin::AppConnectorId)
.collect::<Vec<_>>();
let environment_manager = self.thread_manager.environment_manager();
let app_summaries = plugin_app_helpers::load_plugin_app_summaries(
&config,
&plugin_apps,
&environment_manager,
)
.await;
let app_summaries =
load_plugin_app_summaries(&config, &plugin_apps, &environment_manager).await;
remote_plugin_detail_to_info(remote_detail, app_summaries)
}
};
@@ -302,15 +518,6 @@ impl CodexMessageProcessor {
Ok(PluginReadResponse { plugin })
}
pub(super) async fn plugin_skill_read(
&self,
request_id: ConnectionRequestId,
params: PluginSkillReadParams,
) {
let result = self.plugin_skill_read_response(params).await;
self.outgoing.send_result(request_id, result).await;
}
async fn plugin_skill_read_response(
&self,
params: PluginSkillReadParams,
@@ -357,15 +564,6 @@ impl CodexMessageProcessor {
})
}
pub(super) async fn plugin_share_save(
&self,
request_id: ConnectionRequestId,
params: PluginShareSaveParams,
) {
let result = self.plugin_share_save_response(params).await;
self.outgoing.send_result(request_id, result).await;
}
async fn plugin_share_save_response(
&self,
params: PluginShareSaveParams,
@@ -401,17 +599,9 @@ impl CodexMessageProcessor {
})
}
pub(super) async fn plugin_share_list(
&self,
request_id: ConnectionRequestId,
_params: PluginShareListParams,
) {
let result = self.plugin_share_list_response().await;
self.outgoing.send_result(request_id, result).await;
}
async fn plugin_share_list_response(
&self,
_params: PluginShareListParams,
) -> Result<PluginShareListResponse, JSONRPCErrorError> {
let (config, auth) = self.load_plugin_share_config_and_auth().await?;
let remote_plugin_service_config = RemotePluginServiceConfig {
@@ -442,15 +632,6 @@ impl CodexMessageProcessor {
Ok(PluginShareListResponse { data })
}
pub(super) async fn plugin_share_delete(
&self,
request_id: ConnectionRequestId,
params: PluginShareDeleteParams,
) {
let result = self.plugin_share_delete_response(params).await;
self.outgoing.send_result(request_id, result).await;
}
async fn plugin_share_delete_response(
&self,
params: PluginShareDeleteParams,
@@ -489,15 +670,6 @@ impl CodexMessageProcessor {
Ok((config, auth))
}
pub(super) async fn plugin_install(
&self,
request_id: ConnectionRequestId,
params: PluginInstallParams,
) {
let result = self.plugin_install_response(params).await;
self.outgoing.send_result(request_id, result).await;
}
async fn plugin_install_response(
&self,
params: PluginInstallParams,
@@ -751,7 +923,7 @@ impl CodexMessageProcessor {
);
}
plugin_app_helpers::plugin_apps_needing_auth(
plugin_apps_needing_auth(
&all_connectors,
&accessible_connectors,
plugin_apps,
@@ -759,13 +931,82 @@ impl CodexMessageProcessor {
)
}
pub(super) async fn plugin_uninstall(
async fn start_plugin_mcp_oauth_logins(
&self,
request_id: ConnectionRequestId,
params: PluginUninstallParams,
config: &Config,
plugin_mcp_servers: HashMap<String, McpServerConfig>,
) {
let result = self.plugin_uninstall_response(params).await;
self.outgoing.send_result(request_id, result).await;
for (name, server) in plugin_mcp_servers {
let oauth_config = match oauth_login_support(&server.transport).await {
McpOAuthLoginSupport::Supported(config) => config,
McpOAuthLoginSupport::Unsupported => continue,
McpOAuthLoginSupport::Unknown(err) => {
warn!(
"MCP server may or may not require login for plugin install {name}: {err}"
);
continue;
}
};
let resolved_scopes = resolve_oauth_scopes(
/*explicit_scopes*/ None,
server.scopes.clone(),
oauth_config.discovered_scopes.clone(),
);
let store_mode = config.mcp_oauth_credentials_store_mode;
let callback_port = config.mcp_oauth_callback_port;
let callback_url = config.mcp_oauth_callback_url.clone();
let outgoing = Arc::clone(&self.outgoing);
let notification_name = name.clone();
tokio::spawn(async move {
let first_attempt = perform_oauth_login_silent(
&name,
&oauth_config.url,
store_mode,
oauth_config.http_headers.clone(),
oauth_config.env_http_headers.clone(),
&resolved_scopes.scopes,
server.oauth_resource.as_deref(),
callback_port,
callback_url.as_deref(),
)
.await;
let final_result = match first_attempt {
Err(err) if should_retry_without_scopes(&resolved_scopes, &err) => {
perform_oauth_login_silent(
&name,
&oauth_config.url,
store_mode,
oauth_config.http_headers,
oauth_config.env_http_headers,
&[],
server.oauth_resource.as_deref(),
callback_port,
callback_url.as_deref(),
)
.await
}
result => result,
};
let (success, error) = match final_result {
Ok(()) => (true, None),
Err(err) => (false, Some(err.to_string())),
};
let notification = ServerNotification::McpServerOauthLoginCompleted(
McpServerOauthLoginCompletedNotification {
name: notification_name,
success,
error,
},
);
outgoing.send_server_notification(notification).await;
});
}
}
async fn plugin_uninstall_response(
@@ -916,6 +1157,108 @@ fn is_valid_remote_uninstall_plugin_id(plugin_name: &str) -> bool {
|| plugin_name.starts_with("connector_"))
}
async fn load_plugin_app_summaries(
config: &Config,
plugin_apps: &[codex_plugin::AppConnectorId],
environment_manager: &EnvironmentManager,
) -> Vec<AppSummary> {
if plugin_apps.is_empty() {
return Vec::new();
}
let connectors =
match connectors::list_all_connectors_with_options(config, /*force_refetch*/ false).await {
Ok(connectors) => connectors,
Err(err) => {
warn!("failed to load app metadata for plugin/read: {err:#}");
connectors::list_cached_all_connectors(config)
.await
.unwrap_or_default()
}
};
let plugin_connectors = connectors::connectors_for_plugin_apps(connectors, plugin_apps);
let accessible_connectors =
match connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
config,
/*force_refetch*/ false,
environment_manager,
)
.await
{
Ok(status) if status.codex_apps_ready => status.connectors,
Ok(_) => {
return plugin_connectors
.into_iter()
.map(AppSummary::from)
.collect();
}
Err(err) => {
warn!("failed to load app auth state for plugin/read: {err:#}");
return plugin_connectors
.into_iter()
.map(AppSummary::from)
.collect();
}
};
let accessible_ids = accessible_connectors
.iter()
.map(|connector| connector.id.as_str())
.collect::<HashSet<_>>();
plugin_connectors
.into_iter()
.map(|connector| {
let needs_auth = !accessible_ids.contains(connector.id.as_str());
AppSummary {
id: connector.id,
name: connector.name,
description: connector.description,
install_url: connector.install_url,
needs_auth,
}
})
.collect()
}
fn plugin_apps_needing_auth(
all_connectors: &[AppInfo],
accessible_connectors: &[AppInfo],
plugin_apps: &[codex_plugin::AppConnectorId],
codex_apps_ready: bool,
) -> Vec<AppSummary> {
if !codex_apps_ready {
return Vec::new();
}
let accessible_ids = accessible_connectors
.iter()
.map(|connector| connector.id.as_str())
.collect::<HashSet<_>>();
let plugin_app_ids = plugin_apps
.iter()
.map(|connector_id| connector_id.0.as_str())
.collect::<HashSet<_>>();
all_connectors
.iter()
.filter(|connector| {
plugin_app_ids.contains(connector.id.as_str())
&& !accessible_ids.contains(connector.id.as_str())
})
.cloned()
.map(|connector| AppSummary {
id: connector.id,
name: connector.name,
description: connector.description,
install_url: connector.install_url,
needs_auth: true,
})
.collect()
}
fn remote_marketplace_to_info(marketplace: RemoteMarketplace) -> PluginMarketplaceEntry {
PluginMarketplaceEntry {
name: marketplace.name,
@@ -975,28 +1318,16 @@ fn remote_plugin_catalog_error_to_jsonrpc(
err: RemotePluginCatalogError,
context: &str,
) -> JSONRPCErrorError {
match err {
let code = match &err {
RemotePluginCatalogError::AuthRequired | RemotePluginCatalogError::UnsupportedAuthMode => {
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("{context}: {err}"),
data: None,
}
INVALID_REQUEST_ERROR_CODE
}
RemotePluginCatalogError::UnexpectedStatus { status, .. } if status.as_u16() == 404 => {
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("{context}: {err}"),
data: None,
}
INVALID_REQUEST_ERROR_CODE
}
RemotePluginCatalogError::InvalidPluginPath { .. }
| RemotePluginCatalogError::ArchiveTooLarge { .. }
| RemotePluginCatalogError::UnknownMarketplace { .. } => JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("{context}: {err}"),
data: None,
},
| RemotePluginCatalogError::UnknownMarketplace { .. } => INVALID_REQUEST_ERROR_CODE,
RemotePluginCatalogError::AuthToken(_)
| RemotePluginCatalogError::Request { .. }
| RemotePluginCatalogError::UnexpectedStatus { .. }
@@ -1010,11 +1341,12 @@ fn remote_plugin_catalog_error_to_jsonrpc(
| RemotePluginCatalogError::ArchiveJoin(_)
| RemotePluginCatalogError::MissingUploadEtag
| RemotePluginCatalogError::UnexpectedResponse(_)
| RemotePluginCatalogError::CacheRemove(_) => JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("{context}: {err}"),
data: None,
},
| RemotePluginCatalogError::CacheRemove(_) => INTERNAL_ERROR_CODE,
};
JSONRPCErrorError {
code,
message: format!("{context}: {err}"),
data: None,
}
}
@@ -0,0 +1,8 @@
use super::*;
pub(super) fn environment_selection_error_message(err: CodexErr) -> String {
match err {
CodexErr::InvalidRequest(message) => message,
err => err.to_string(),
}
}
@@ -0,0 +1,150 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::fuzzy_file_search::FuzzyFileSearchSession;
use crate::fuzzy_file_search::run_fuzzy_file_search;
use crate::fuzzy_file_search::start_fuzzy_file_search_session;
use crate::outgoing_message::OutgoingMessageSender;
use codex_app_server_protocol::FuzzyFileSearchParams;
use codex_app_server_protocol::FuzzyFileSearchResponse;
use codex_app_server_protocol::FuzzyFileSearchSessionStartParams;
use codex_app_server_protocol::FuzzyFileSearchSessionStartResponse;
use codex_app_server_protocol::FuzzyFileSearchSessionStopParams;
use codex_app_server_protocol::FuzzyFileSearchSessionStopResponse;
use codex_app_server_protocol::FuzzyFileSearchSessionUpdateParams;
use codex_app_server_protocol::FuzzyFileSearchSessionUpdateResponse;
use codex_app_server_protocol::JSONRPCErrorError;
use tokio::sync::Mutex;
#[derive(Clone)]
pub(crate) struct SearchRequestProcessor {
outgoing: Arc<OutgoingMessageSender>,
pending_fuzzy_searches: Arc<Mutex<HashMap<String, Arc<AtomicBool>>>>,
fuzzy_search_sessions: Arc<Mutex<HashMap<String, FuzzyFileSearchSession>>>,
}
impl SearchRequestProcessor {
pub(crate) fn new(outgoing: Arc<OutgoingMessageSender>) -> Self {
Self {
outgoing,
pending_fuzzy_searches: Arc::new(Mutex::new(HashMap::new())),
fuzzy_search_sessions: Arc::new(Mutex::new(HashMap::new())),
}
}
pub(crate) async fn fuzzy_file_search(
&self,
params: FuzzyFileSearchParams,
) -> Result<FuzzyFileSearchResponse, JSONRPCErrorError> {
let FuzzyFileSearchParams {
query,
roots,
cancellation_token,
} = params;
let cancel_flag = match cancellation_token.clone() {
Some(token) => {
let mut pending_fuzzy_searches = self.pending_fuzzy_searches.lock().await;
// if a cancellation_token is provided and a pending_request exists for
// that token, cancel it
if let Some(existing) = pending_fuzzy_searches.get(&token) {
existing.store(true, Ordering::Relaxed);
}
let flag = Arc::new(AtomicBool::new(false));
pending_fuzzy_searches.insert(token.clone(), flag.clone());
flag
}
None => Arc::new(AtomicBool::new(false)),
};
let results = match query.as_str() {
"" => vec![],
_ => run_fuzzy_file_search(query, roots, cancel_flag.clone()).await,
};
if let Some(token) = cancellation_token {
let mut pending_fuzzy_searches = self.pending_fuzzy_searches.lock().await;
if let Some(current_flag) = pending_fuzzy_searches.get(&token)
&& Arc::ptr_eq(current_flag, &cancel_flag)
{
pending_fuzzy_searches.remove(&token);
}
}
Ok(FuzzyFileSearchResponse { files: results })
}
pub(crate) async fn fuzzy_file_search_session_start_response(
&self,
params: FuzzyFileSearchSessionStartParams,
) -> Result<FuzzyFileSearchSessionStartResponse, JSONRPCErrorError> {
let FuzzyFileSearchSessionStartParams { session_id, roots } = params;
if session_id.is_empty() {
return Err(invalid_request("sessionId must not be empty"));
}
let session =
start_fuzzy_file_search_session(session_id.clone(), roots, self.outgoing.clone())
.map_err(|err| {
internal_error(format!("failed to start fuzzy file search session: {err}"))
})?;
self.fuzzy_search_sessions
.lock()
.await
.insert(session_id, session);
Ok(FuzzyFileSearchSessionStartResponse {})
}
pub(crate) async fn fuzzy_file_search_session_update_response(
&self,
params: FuzzyFileSearchSessionUpdateParams,
) -> Result<FuzzyFileSearchSessionUpdateResponse, JSONRPCErrorError> {
let FuzzyFileSearchSessionUpdateParams { session_id, query } = params;
let found = {
let sessions = self.fuzzy_search_sessions.lock().await;
if let Some(session) = sessions.get(&session_id) {
session.update_query(query);
true
} else {
false
}
};
if !found {
return Err(invalid_request(format!(
"fuzzy file search session not found: {session_id}"
)));
}
Ok(FuzzyFileSearchSessionUpdateResponse {})
}
pub(crate) async fn fuzzy_file_search_session_stop(
&self,
params: FuzzyFileSearchSessionStopParams,
) -> Result<FuzzyFileSearchSessionStopResponse, JSONRPCErrorError> {
let FuzzyFileSearchSessionStopParams { session_id } = params;
self.fuzzy_search_sessions.lock().await.remove(&session_id);
Ok(FuzzyFileSearchSessionStopResponse {})
}
}
fn invalid_request(message: impl Into<String>) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: message.into(),
data: None,
}
}
fn internal_error(message: impl Into<String>) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: message.into(),
data: None,
}
}
@@ -1,68 +1,115 @@
use super::*;
use codex_protocol::protocol::validate_thread_goal_objective;
impl CodexMessageProcessor {
pub(super) async fn thread_goal_set(
#[derive(Clone)]
pub(crate) struct ThreadGoalRequestProcessor {
thread_manager: Arc<ThreadManager>,
outgoing: Arc<OutgoingMessageSender>,
config: Arc<Config>,
thread_state_manager: ThreadStateManager,
}
impl ThreadGoalRequestProcessor {
pub(crate) fn new(
thread_manager: Arc<ThreadManager>,
outgoing: Arc<OutgoingMessageSender>,
config: Arc<Config>,
thread_state_manager: ThreadStateManager,
) -> Self {
Self {
thread_manager,
outgoing,
config,
thread_state_manager,
}
}
pub(crate) async fn thread_goal_set(
&self,
request_id: ConnectionRequestId,
params: ThreadGoalSetParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_goal_set_inner(request_id, params)
.await
.map(|()| None)
}
pub(crate) async fn thread_goal_get(
&self,
params: ThreadGoalGetParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_goal_get_inner(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn thread_goal_clear(
&self,
request_id: ConnectionRequestId,
params: ThreadGoalClearParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_goal_clear_inner(request_id, params)
.await
.map(|()| None)
}
pub(crate) async fn emit_resume_goal_snapshot_and_continue(
&self,
thread_id: ThreadId,
thread: &CodexThread,
) {
if !self.config.features.enabled(Feature::Goals) {
self.send_invalid_request_error(request_id, "goals feature is disabled".to_string())
.await;
return;
}
self.emit_thread_goal_snapshot(thread_id).await;
// App-server owns resume response and snapshot ordering, so wait until
// those are sent before letting core start goal continuation.
if let Err(err) = thread.continue_active_goal_if_idle().await {
tracing::warn!("failed to continue active goal after resume: {err}");
}
}
let thread_id = match parse_thread_id_for_request(params.thread_id.as_str()) {
Ok(thread_id) => thread_id,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
let state_db = match self.state_db_for_materialized_thread(thread_id).await {
Ok(state_db) => state_db,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
pub(crate) async fn pending_resume_goal_state(
&self,
thread: &CodexThread,
) -> (bool, Option<StateDbHandle>) {
let emit_thread_goal_update = self.config.features.enabled(Feature::Goals);
let thread_goal_state_db = if emit_thread_goal_update {
if let Some(state_db) = thread.state_db() {
Some(state_db)
} else {
open_state_db_for_direct_thread_lookup(&self.config).await
}
} else {
None
};
(emit_thread_goal_update, thread_goal_state_db)
}
async fn thread_goal_set_inner(
&self,
request_id: ConnectionRequestId,
params: ThreadGoalSetParams,
) -> Result<(), JSONRPCErrorError> {
if !self.config.features.enabled(Feature::Goals) {
return Err(invalid_request("goals feature is disabled"));
}
let thread_id = parse_thread_id_for_request(params.thread_id.as_str())?;
let state_db = self.state_db_for_materialized_thread(thread_id).await?;
let running_thread = self.thread_manager.get_thread(thread_id).await.ok();
let rollout_path = match running_thread.as_ref() {
Some(thread) => match thread.rollout_path() {
Some(path) => path,
None => {
self.send_invalid_request_error(
request_id,
format!("ephemeral thread does not support goals: {thread_id}"),
)
.await;
return;
}
},
None => {
match find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string())
.await
{
Ok(Some(path)) => path,
Ok(None) => {
self.send_invalid_request_error(
request_id,
format!("thread not found: {thread_id}"),
)
.await;
return;
}
Err(err) => {
self.send_internal_error(
request_id,
format!("failed to locate thread id {thread_id}: {err}"),
)
.await;
return;
}
}
}
Some(thread) => thread.rollout_path().ok_or_else(|| {
invalid_request(format!(
"ephemeral thread does not support goals: {thread_id}"
))
})?,
None => find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string())
.await
.map_err(|err| {
internal_error(format!("failed to locate thread id {thread_id}: {err}"))
})?
.ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?,
};
reconcile_rollout(
Some(&state_db),
@@ -84,61 +131,51 @@ impl CodexMessageProcessor {
let objective = params.objective.as_deref().map(str::trim);
if let Some(objective) = objective {
if let Err(message) = validate_thread_goal_objective(objective) {
self.send_invalid_request_error(request_id, message).await;
return;
}
if let Err(message) = validate_goal_budget(params.token_budget.flatten()) {
self.send_invalid_request_error(request_id, message).await;
return;
}
} else if let Some(token_budget) = params.token_budget
&& let Err(message) = validate_goal_budget(token_budget)
{
self.send_invalid_request_error(request_id, message).await;
return;
validate_thread_goal_objective(objective).map_err(invalid_request)?;
}
if objective.is_some() || params.token_budget.is_some() {
validate_goal_budget(params.token_budget.flatten()).map_err(invalid_request)?;
}
if let Some(thread) = running_thread.as_ref() {
thread.prepare_external_goal_mutation().await;
}
let goal = if let Some(objective) = objective {
match state_db.get_thread_goal(thread_id).await {
Ok(goal) => {
if let Some(goal) = goal.as_ref().filter(|goal| {
goal.objective == objective
&& goal.status != codex_state::ThreadGoalStatus::Complete
}) {
state_db
.update_thread_goal(
thread_id,
codex_state::ThreadGoalUpdate {
status,
token_budget: params.token_budget,
expected_goal_id: Some(goal.goal_id.clone()),
},
let goal = (if let Some(objective) = objective {
let existing_goal = state_db
.get_thread_goal(thread_id)
.await
.map_err(|err| invalid_request(err.to_string()))?;
if let Some(goal) = existing_goal.as_ref().filter(|goal| {
goal.objective == objective
&& goal.status != codex_state::ThreadGoalStatus::Complete
}) {
state_db
.update_thread_goal(
thread_id,
codex_state::ThreadGoalUpdate {
status,
token_budget: params.token_budget,
expected_goal_id: Some(goal.goal_id.clone()),
},
)
.await
.and_then(|goal| {
goal.ok_or_else(|| {
anyhow::anyhow!(
"cannot update goal for thread {thread_id}: no goal exists"
)
.await
.and_then(|goal| {
goal.ok_or_else(|| {
anyhow::anyhow!(
"cannot update goal for thread {thread_id}: no goal exists"
)
})
})
} else {
state_db
.replace_thread_goal(
thread_id,
objective,
status.unwrap_or(codex_state::ThreadGoalStatus::Active),
params.token_budget.flatten(),
)
.await
}
}
Err(err) => Err(err),
})
})
} else {
state_db
.replace_thread_goal(
thread_id,
objective,
status.unwrap_or(codex_state::ThreadGoalStatus::Active),
params.token_budget.flatten(),
)
.await
}
} else {
state_db
@@ -156,16 +193,8 @@ impl CodexMessageProcessor {
anyhow::anyhow!("cannot update goal for thread {thread_id}: no goal exists")
})
})
};
let goal = match goal {
Ok(goal) => goal,
Err(err) => {
self.send_invalid_request_error(request_id, err.to_string())
.await;
return;
}
};
})
.map_err(|err| invalid_request(err.to_string()))?;
let goal_status = goal.status;
let goal = api_thread_goal_from_state(goal);
self.outgoing
@@ -179,107 +208,51 @@ impl CodexMessageProcessor {
if let Some(thread) = running_thread.as_ref() {
thread.apply_external_goal_set(goal_status).await;
}
Ok(())
}
pub(super) async fn thread_goal_get(
async fn thread_goal_get_inner(
&self,
request_id: ConnectionRequestId,
params: ThreadGoalGetParams,
) {
) -> Result<ThreadGoalGetResponse, JSONRPCErrorError> {
if !self.config.features.enabled(Feature::Goals) {
self.send_invalid_request_error(request_id, "goals feature is disabled".to_string())
.await;
return;
return Err(invalid_request("goals feature is disabled"));
}
let thread_id = match parse_thread_id_for_request(params.thread_id.as_str()) {
Ok(thread_id) => thread_id,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
let state_db = match self.state_db_for_materialized_thread(thread_id).await {
Ok(state_db) => state_db,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
let goal = match state_db.get_thread_goal(thread_id).await {
Ok(goal) => goal.map(api_thread_goal_from_state),
Err(err) => {
self.send_internal_error(request_id, format!("failed to read thread goal: {err}"))
.await;
return;
}
};
self.outgoing
.send_response(request_id, ThreadGoalGetResponse { goal })
.await;
let thread_id = parse_thread_id_for_request(params.thread_id.as_str())?;
let state_db = self.state_db_for_materialized_thread(thread_id).await?;
let goal = state_db
.get_thread_goal(thread_id)
.await
.map_err(|err| internal_error(format!("failed to read thread goal: {err}")))?
.map(api_thread_goal_from_state);
Ok(ThreadGoalGetResponse { goal })
}
pub(super) async fn thread_goal_clear(
async fn thread_goal_clear_inner(
&self,
request_id: ConnectionRequestId,
params: ThreadGoalClearParams,
) {
) -> Result<(), JSONRPCErrorError> {
if !self.config.features.enabled(Feature::Goals) {
self.send_invalid_request_error(request_id, "goals feature is disabled".to_string())
.await;
return;
return Err(invalid_request("goals feature is disabled"));
}
let thread_id = match parse_thread_id_for_request(params.thread_id.as_str()) {
Ok(thread_id) => thread_id,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
let state_db = match self.state_db_for_materialized_thread(thread_id).await {
Ok(state_db) => state_db,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
let thread_id = parse_thread_id_for_request(params.thread_id.as_str())?;
let state_db = self.state_db_for_materialized_thread(thread_id).await?;
let running_thread = self.thread_manager.get_thread(thread_id).await.ok();
let rollout_path = match running_thread.as_ref() {
Some(thread) => match thread.rollout_path() {
Some(path) => path,
None => {
self.send_invalid_request_error(
request_id,
format!("ephemeral thread does not support goals: {thread_id}"),
)
.await;
return;
}
},
None => {
match find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string())
.await
{
Ok(Some(path)) => path,
Ok(None) => {
self.send_invalid_request_error(
request_id,
format!("thread not found: {thread_id}"),
)
.await;
return;
}
Err(err) => {
self.send_internal_error(
request_id,
format!("failed to locate thread id {thread_id}: {err}"),
)
.await;
return;
}
}
}
Some(thread) => thread.rollout_path().ok_or_else(|| {
invalid_request(format!(
"ephemeral thread does not support goals: {thread_id}"
))
})?,
None => find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string())
.await
.map_err(|err| {
internal_error(format!("failed to locate thread id {thread_id}: {err}"))
})?
.ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?,
};
reconcile_rollout(
Some(&state_db),
@@ -301,14 +274,10 @@ impl CodexMessageProcessor {
let thread_state = thread_state.lock().await;
thread_state.listener_command_tx()
};
let cleared = match state_db.delete_thread_goal(thread_id).await {
Ok(cleared) => cleared,
Err(err) => {
self.send_internal_error(request_id, format!("failed to clear thread goal: {err}"))
.await;
return;
}
};
let cleared = state_db
.delete_thread_goal(thread_id)
.await
.map_err(|err| internal_error(format!("failed to clear thread goal: {err}")))?;
if cleared && let Some(thread) = running_thread.as_ref() {
thread.apply_external_goal_clear().await;
@@ -321,6 +290,7 @@ impl CodexMessageProcessor {
self.emit_thread_goal_cleared_ordered(thread_id, listener_command_tx)
.await;
}
Ok(())
}
async fn state_db_for_materialized_thread(
@@ -337,18 +307,12 @@ impl CodexMessageProcessor {
return Ok(state_db);
}
} else {
match find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string()).await
{
Ok(Some(_)) => {}
Ok(None) => {
return Err(invalid_request(format!("thread not found: {thread_id}")));
}
Err(err) => {
return Err(internal_error(format!(
"failed to locate thread id {thread_id}: {err}"
)));
}
}
find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string())
.await
.map_err(|err| {
internal_error(format!("failed to locate thread id {thread_id}: {err}"))
})?
.ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?;
}
open_state_db_for_direct_thread_lookup(&self.config)
@@ -356,7 +320,7 @@ impl CodexMessageProcessor {
.ok_or_else(|| internal_error("sqlite state db unavailable for thread goals"))
}
pub(super) async fn emit_thread_goal_snapshot(&self, thread_id: ThreadId) {
async fn emit_thread_goal_snapshot(&self, thread_id: ThreadId) {
let state_db = match self.state_db_for_materialized_thread(thread_id).await {
Ok(state_db) => state_db,
Err(err) => {
@@ -477,3 +441,8 @@ pub(super) fn api_thread_goal_from_state(goal: codex_state::ThreadGoal) -> Threa
updated_at: goal.updated_at.timestamp(),
}
}
fn parse_thread_id_for_request(thread_id: &str) -> Result<ThreadId, JSONRPCErrorError> {
ThreadId::from_string(thread_id)
.map_err(|err| invalid_request(format!("invalid thread id: {err}")))
}
@@ -0,0 +1,767 @@
use super::*;
pub(super) const THREAD_UNLOADING_DELAY: Duration = Duration::from_secs(30 * 60);
#[derive(Clone)]
pub(super) struct ListenerTaskContext {
pub(super) thread_manager: Arc<ThreadManager>,
pub(super) thread_state_manager: ThreadStateManager,
pub(super) outgoing: Arc<OutgoingMessageSender>,
pub(super) pending_thread_unloads: Arc<Mutex<HashSet<ThreadId>>>,
pub(super) analytics_events_client: AnalyticsEventsClient,
pub(super) thread_watch_manager: ThreadWatchManager,
pub(super) thread_list_state_permit: Arc<Semaphore>,
pub(super) fallback_model_provider: String,
pub(super) codex_home: PathBuf,
}
struct UnloadingState {
delay: Duration,
has_subscribers_rx: watch::Receiver<bool>,
has_subscribers: (bool, Instant),
thread_status_rx: watch::Receiver<ThreadStatus>,
is_active: (bool, Instant),
}
impl UnloadingState {
async fn new(
listener_task_context: &ListenerTaskContext,
thread_id: ThreadId,
delay: Duration,
) -> Option<Self> {
let has_subscribers_rx = listener_task_context
.thread_state_manager
.subscribe_to_has_connections(thread_id)
.await?;
let thread_status_rx = listener_task_context
.thread_watch_manager
.subscribe(thread_id)
.await?;
let has_subscribers = (*has_subscribers_rx.borrow(), Instant::now());
let is_active = (
matches!(*thread_status_rx.borrow(), ThreadStatus::Active { .. }),
Instant::now(),
);
Some(Self {
delay,
has_subscribers_rx,
has_subscribers,
thread_status_rx,
is_active,
})
}
fn unloading_target(&self) -> Option<Instant> {
match (self.has_subscribers, self.is_active) {
((false, has_no_subscribers_since), (false, is_inactive_since)) => {
Some(std::cmp::max(has_no_subscribers_since, is_inactive_since) + self.delay)
}
_ => None,
}
}
fn sync_receiver_values(&mut self) {
let has_subscribers = *self.has_subscribers_rx.borrow();
if self.has_subscribers.0 != has_subscribers {
self.has_subscribers = (has_subscribers, Instant::now());
}
let is_active = matches!(*self.thread_status_rx.borrow(), ThreadStatus::Active { .. });
if self.is_active.0 != is_active {
self.is_active = (is_active, Instant::now());
}
}
fn should_unload_now(&mut self) -> bool {
self.sync_receiver_values();
self.unloading_target()
.is_some_and(|target| target <= Instant::now())
}
fn note_thread_activity_observed(&mut self) {
if !self.is_active.0 {
self.is_active = (false, Instant::now());
}
}
async fn wait_for_unloading_trigger(&mut self) -> bool {
loop {
self.sync_receiver_values();
let unloading_target = self.unloading_target();
if let Some(target) = unloading_target
&& target <= Instant::now()
{
return true;
}
let unloading_sleep = async {
if let Some(target) = unloading_target {
tokio::time::sleep_until(target.into()).await;
} else {
futures::future::pending::<()>().await;
}
};
tokio::select! {
_ = unloading_sleep => return true,
changed = self.has_subscribers_rx.changed() => {
if changed.is_err() {
return false;
}
self.sync_receiver_values();
},
changed = self.thread_status_rx.changed() => {
if changed.is_err() {
return false;
}
self.sync_receiver_values();
},
}
}
}
}
pub(super) enum ThreadShutdownResult {
Complete,
SubmitFailed,
TimedOut,
}
pub(super) enum EnsureConversationListenerResult {
Attached,
ConnectionClosed,
}
#[expect(
clippy::await_holding_invalid_type,
reason = "listener subscription must be serialized against pending unloads"
)]
pub(super) async fn ensure_conversation_listener(
listener_task_context: ListenerTaskContext,
conversation_id: ThreadId,
connection_id: ConnectionId,
raw_events_enabled: bool,
) -> Result<EnsureConversationListenerResult, JSONRPCErrorError> {
let conversation = match listener_task_context
.thread_manager
.get_thread(conversation_id)
.await
{
Ok(conv) => conv,
Err(_) => {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("thread not found: {conversation_id}"),
data: None,
});
}
};
let thread_state = {
let pending_thread_unloads = listener_task_context.pending_thread_unloads.lock().await;
if pending_thread_unloads.contains(&conversation_id) {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!(
"thread {conversation_id} is closing; retry after the thread is closed"
),
data: None,
});
}
let Some(thread_state) = listener_task_context
.thread_state_manager
.try_ensure_connection_subscribed(conversation_id, connection_id, raw_events_enabled)
.await
else {
return Ok(EnsureConversationListenerResult::ConnectionClosed);
};
thread_state
};
if let Err(error) = ensure_listener_task_running(
listener_task_context.clone(),
conversation_id,
conversation,
thread_state,
)
.await
{
let _ = listener_task_context
.thread_state_manager
.unsubscribe_connection_from_thread(conversation_id, connection_id)
.await;
return Err(error);
}
Ok(EnsureConversationListenerResult::Attached)
}
pub(super) fn log_listener_attach_result(
result: Result<EnsureConversationListenerResult, JSONRPCErrorError>,
thread_id: ThreadId,
connection_id: ConnectionId,
thread_kind: &'static str,
) {
match result {
Ok(EnsureConversationListenerResult::Attached) => {}
Ok(EnsureConversationListenerResult::ConnectionClosed) => {
tracing::debug!(
thread_id = %thread_id,
connection_id = ?connection_id,
"skipping auto-attach for closed connection"
);
}
Err(err) => {
tracing::warn!(
"failed to attach listener for {thread_kind} {thread_id}: {message}",
message = err.message
);
}
}
}
pub(super) async fn ensure_listener_task_running(
listener_task_context: ListenerTaskContext,
conversation_id: ThreadId,
conversation: Arc<CodexThread>,
thread_state: Arc<Mutex<ThreadState>>,
) -> Result<(), JSONRPCErrorError> {
let (cancel_tx, mut cancel_rx) = oneshot::channel();
let Some(mut unloading_state) = UnloadingState::new(
&listener_task_context,
conversation_id,
THREAD_UNLOADING_DELAY,
)
.await
else {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!(
"thread {conversation_id} is closing; retry after the thread is closed"
),
data: None,
});
};
let (mut listener_command_rx, listener_generation) = {
let mut thread_state = thread_state.lock().await;
if thread_state.listener_matches(&conversation) {
return Ok(());
}
thread_state.set_listener(cancel_tx, &conversation)
};
let ListenerTaskContext {
outgoing,
thread_manager,
thread_state_manager,
pending_thread_unloads,
analytics_events_client,
thread_watch_manager,
thread_list_state_permit,
fallback_model_provider,
codex_home,
} = listener_task_context;
let outgoing_for_task = Arc::clone(&outgoing);
tokio::spawn(async move {
loop {
tokio::select! {
biased;
_ = &mut cancel_rx => {
// Listener was superseded or the thread is being torn down.
break;
}
listener_command = listener_command_rx.recv() => {
let Some(listener_command) = listener_command else {
break;
};
handle_thread_listener_command(
conversation_id,
&conversation,
codex_home.as_path(),
&thread_state_manager,
&thread_state,
&thread_watch_manager,
&outgoing_for_task,
&pending_thread_unloads,
listener_command,
)
.await;
}
event = conversation.next_event() => {
let event = match event {
Ok(event) => event,
Err(err) => {
tracing::warn!("thread.next_event() failed with: {err}");
break;
}
};
// Track the event before emitting any typed translations
// so thread-local state such as raw event opt-in stays
// synchronized with the conversation.
let raw_events_enabled = {
let mut thread_state = thread_state.lock().await;
thread_state.track_current_turn_event(&event.id, &event.msg);
thread_state.experimental_raw_events
};
let subscribed_connection_ids = thread_state_manager
.subscribed_connection_ids(conversation_id)
.await;
let thread_outgoing = ThreadScopedOutgoingMessageSender::new(
outgoing_for_task.clone(),
subscribed_connection_ids,
conversation_id,
);
if let EventMsg::RawResponseItem(raw_response_item_event) = &event.msg
&& !raw_events_enabled
{
maybe_emit_hook_prompt_item_completed(
conversation_id,
&event.id,
&raw_response_item_event.item,
&thread_outgoing,
)
.await;
continue;
}
apply_bespoke_event_handling(
event.clone(),
conversation_id,
conversation.clone(),
thread_manager.clone(),
Some(analytics_events_client.clone()),
thread_outgoing,
thread_state.clone(),
thread_watch_manager.clone(),
thread_list_state_permit.clone(),
fallback_model_provider.clone(),
codex_home.as_path(),
)
.await;
}
unloading_watchers_open = unloading_state.wait_for_unloading_trigger() => {
if !unloading_watchers_open {
break;
}
if !unloading_state.should_unload_now() {
continue;
}
if matches!(conversation.agent_status().await, AgentStatus::Running) {
unloading_state.note_thread_activity_observed();
continue;
}
{
let mut pending_thread_unloads = pending_thread_unloads.lock().await;
if pending_thread_unloads.contains(&conversation_id) {
continue;
}
if !unloading_state.should_unload_now() {
continue;
}
pending_thread_unloads.insert(conversation_id);
}
unload_thread_without_subscribers(
thread_manager.clone(),
outgoing_for_task.clone(),
pending_thread_unloads.clone(),
thread_state_manager.clone(),
thread_watch_manager.clone(),
conversation_id,
conversation.clone(),
)
.await;
break;
}
}
}
let mut thread_state = thread_state.lock().await;
if thread_state.listener_generation == listener_generation {
thread_state.clear_listener();
}
});
Ok(())
}
pub(super) async fn wait_for_thread_shutdown(thread: &Arc<CodexThread>) -> ThreadShutdownResult {
match tokio::time::timeout(Duration::from_secs(10), thread.shutdown_and_wait()).await {
Ok(Ok(())) => ThreadShutdownResult::Complete,
Ok(Err(_)) => ThreadShutdownResult::SubmitFailed,
Err(_) => ThreadShutdownResult::TimedOut,
}
}
pub(super) async fn unload_thread_without_subscribers(
thread_manager: Arc<ThreadManager>,
outgoing: Arc<OutgoingMessageSender>,
pending_thread_unloads: Arc<Mutex<HashSet<ThreadId>>>,
thread_state_manager: ThreadStateManager,
thread_watch_manager: ThreadWatchManager,
thread_id: ThreadId,
thread: Arc<CodexThread>,
) {
info!("thread {thread_id} has no subscribers and is idle; shutting down");
// Any pending app-server -> client requests for this thread can no longer be
// answered; cancel their callbacks before shutdown/unload.
outgoing
.cancel_requests_for_thread(thread_id, /*error*/ None)
.await;
thread_state_manager.remove_thread_state(thread_id).await;
tokio::spawn(async move {
match wait_for_thread_shutdown(&thread).await {
ThreadShutdownResult::Complete => {
if thread_manager.remove_thread(&thread_id).await.is_none() {
info!("thread {thread_id} was already removed before teardown finalized");
thread_watch_manager
.remove_thread(&thread_id.to_string())
.await;
pending_thread_unloads.lock().await.remove(&thread_id);
return;
}
thread_watch_manager
.remove_thread(&thread_id.to_string())
.await;
let notification = ThreadClosedNotification {
thread_id: thread_id.to_string(),
};
outgoing
.send_server_notification(ServerNotification::ThreadClosed(notification))
.await;
pending_thread_unloads.lock().await.remove(&thread_id);
}
ThreadShutdownResult::SubmitFailed => {
pending_thread_unloads.lock().await.remove(&thread_id);
warn!("failed to submit Shutdown to thread {thread_id}");
}
ThreadShutdownResult::TimedOut => {
pending_thread_unloads.lock().await.remove(&thread_id);
warn!("thread {thread_id} shutdown timed out; leaving thread loaded");
}
}
});
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn handle_thread_listener_command(
conversation_id: ThreadId,
conversation: &Arc<CodexThread>,
codex_home: &Path,
thread_state_manager: &ThreadStateManager,
thread_state: &Arc<Mutex<ThreadState>>,
thread_watch_manager: &ThreadWatchManager,
outgoing: &Arc<OutgoingMessageSender>,
pending_thread_unloads: &Arc<Mutex<HashSet<ThreadId>>>,
listener_command: ThreadListenerCommand,
) {
match listener_command {
ThreadListenerCommand::SendThreadResumeResponse(resume_request) => {
handle_pending_thread_resume_request(
conversation_id,
conversation,
codex_home,
thread_state_manager,
thread_state,
thread_watch_manager,
outgoing,
pending_thread_unloads,
*resume_request,
)
.await;
}
ThreadListenerCommand::EmitThreadGoalUpdated { goal } => {
outgoing
.send_server_notification(ServerNotification::ThreadGoalUpdated(
ThreadGoalUpdatedNotification {
thread_id: conversation_id.to_string(),
turn_id: None,
goal,
},
))
.await;
}
ThreadListenerCommand::EmitThreadGoalCleared => {
outgoing
.send_server_notification(ServerNotification::ThreadGoalCleared(
ThreadGoalClearedNotification {
thread_id: conversation_id.to_string(),
},
))
.await;
}
ThreadListenerCommand::EmitThreadGoalSnapshot { state_db } => {
send_thread_goal_snapshot_notification(outgoing, conversation_id, &state_db).await;
}
ThreadListenerCommand::ResolveServerRequest {
request_id,
completion_tx,
} => {
resolve_pending_server_request(
conversation_id,
thread_state_manager,
outgoing,
request_id,
)
.await;
let _ = completion_tx.send(());
}
}
}
#[allow(clippy::too_many_arguments)]
#[expect(
clippy::await_holding_invalid_type,
reason = "running-thread resume subscription must be serialized against pending unloads"
)]
pub(super) async fn handle_pending_thread_resume_request(
conversation_id: ThreadId,
conversation: &Arc<CodexThread>,
_codex_home: &Path,
thread_state_manager: &ThreadStateManager,
thread_state: &Arc<Mutex<ThreadState>>,
thread_watch_manager: &ThreadWatchManager,
outgoing: &Arc<OutgoingMessageSender>,
pending_thread_unloads: &Arc<Mutex<HashSet<ThreadId>>>,
pending: crate::thread_state::PendingThreadResumeRequest,
) {
let active_turn = {
let state = thread_state.lock().await;
state.active_turn_snapshot()
};
tracing::debug!(
thread_id = %conversation_id,
request_id = ?pending.request_id,
active_turn_present = active_turn.is_some(),
active_turn_id = ?active_turn.as_ref().map(|turn| turn.id.as_str()),
active_turn_status = ?active_turn.as_ref().map(|turn| &turn.status),
"composing running thread resume response"
);
let has_live_in_progress_turn =
matches!(conversation.agent_status().await, AgentStatus::Running)
|| active_turn
.as_ref()
.is_some_and(|turn| matches!(turn.status, TurnStatus::InProgress));
let request_id = pending.request_id;
let connection_id = request_id.connection_id;
let mut thread = pending.thread_summary;
if pending.include_turns
&& let Err(message) = populate_thread_turns_from_history(
&mut thread,
&pending.history_items,
active_turn.as_ref(),
)
{
outgoing
.send_error(request_id, internal_error(message))
.await;
return;
}
let thread_status = thread_watch_manager
.loaded_status_for_thread(&thread.id)
.await;
set_thread_status_and_interrupt_stale_turns(
&mut thread,
thread_status,
has_live_in_progress_turn,
);
{
let pending_thread_unloads = pending_thread_unloads.lock().await;
if pending_thread_unloads.contains(&conversation_id) {
drop(pending_thread_unloads);
outgoing
.send_error(
request_id,
invalid_request(format!(
"thread {conversation_id} is closing; retry thread/resume after the thread is closed"
)),
)
.await;
return;
}
if !thread_state_manager
.try_add_connection_to_thread(conversation_id, connection_id)
.await
{
tracing::debug!(
thread_id = %conversation_id,
connection_id = ?connection_id,
"skipping running thread resume for closed connection"
);
return;
}
}
if pending.emit_thread_goal_update
&& let Err(err) = conversation.apply_goal_resume_runtime_effects().await
{
tracing::warn!("failed to apply goal resume runtime effects: {err}");
}
let ThreadConfigSnapshot {
model,
model_provider_id,
service_tier,
approval_policy,
approvals_reviewer,
permission_profile,
active_permission_profile,
cwd,
reasoning_effort,
..
} = pending.config_snapshot;
let instruction_sources = pending.instruction_sources;
let sandbox = thread_response_sandbox_policy(&permission_profile, cwd.as_path());
let active_permission_profile =
thread_response_active_permission_profile(active_permission_profile);
let response = ThreadResumeResponse {
thread,
model,
model_provider: model_provider_id,
service_tier,
cwd,
instruction_sources,
approval_policy: approval_policy.into(),
approvals_reviewer: approvals_reviewer.into(),
sandbox,
permission_profile: Some(permission_profile.into()),
active_permission_profile,
reasoning_effort,
};
let token_usage_thread = pending.include_turns.then(|| response.thread.clone());
outgoing.send_response(request_id, response).await;
// Match cold resume: metadata-only resume should attach the listener without
// paying the cost of turn reconstruction for historical usage replay.
if let Some(token_usage_thread) = token_usage_thread {
let token_usage_turn_id = latest_token_usage_turn_id_from_rollout_items(
&pending.history_items,
token_usage_thread.turns.as_slice(),
);
// Rejoining a loaded thread has the same UI contract as a cold resume, but
// uses the live conversation state instead of reconstructing a new session.
send_thread_token_usage_update_to_connection(
outgoing,
connection_id,
conversation_id,
&token_usage_thread,
conversation.as_ref(),
token_usage_turn_id,
)
.await;
}
if pending.emit_thread_goal_update {
if let Some(state_db) = pending.thread_goal_state_db {
send_thread_goal_snapshot_notification(outgoing, conversation_id, &state_db).await;
} else {
tracing::warn!(
thread_id = %conversation_id,
"state db unavailable when reading thread goal for running thread resume"
);
}
}
outgoing
.replay_requests_to_connection_for_thread(connection_id, conversation_id)
.await;
// App-server owns resume response and snapshot ordering, so wait until
// replay completes before letting core start goal continuation.
if pending.emit_thread_goal_update
&& let Err(err) = conversation.continue_active_goal_if_idle().await
{
tracing::warn!("failed to continue active goal after running-thread resume: {err}");
}
}
pub(super) async fn send_thread_goal_snapshot_notification(
outgoing: &Arc<OutgoingMessageSender>,
thread_id: ThreadId,
state_db: &StateDbHandle,
) {
match state_db.get_thread_goal(thread_id).await {
Ok(Some(goal)) => {
outgoing
.send_server_notification(ServerNotification::ThreadGoalUpdated(
ThreadGoalUpdatedNotification {
thread_id: thread_id.to_string(),
turn_id: None,
goal: api_thread_goal_from_state(goal),
},
))
.await;
}
Ok(None) => {
outgoing
.send_server_notification(ServerNotification::ThreadGoalCleared(
ThreadGoalClearedNotification {
thread_id: thread_id.to_string(),
},
))
.await;
}
Err(err) => {
tracing::warn!(
thread_id = %thread_id,
"failed to read thread goal for resume snapshot: {err}"
);
}
}
}
pub(super) fn populate_thread_turns_from_history(
thread: &mut Thread,
items: &[RolloutItem],
active_turn: Option<&Turn>,
) -> std::result::Result<(), String> {
let mut turns = build_turns_from_rollout_items(items);
if let Some(active_turn) = active_turn {
merge_turn_history_with_active_turn(&mut turns, active_turn.clone());
}
thread.turns = turns;
Ok(())
}
pub(super) async fn resolve_pending_server_request(
conversation_id: ThreadId,
thread_state_manager: &ThreadStateManager,
outgoing: &Arc<OutgoingMessageSender>,
request_id: RequestId,
) {
let thread_id = conversation_id.to_string();
let subscribed_connection_ids = thread_state_manager
.subscribed_connection_ids(conversation_id)
.await;
let outgoing = ThreadScopedOutgoingMessageSender::new(
outgoing.clone(),
subscribed_connection_ids,
conversation_id,
);
outgoing
.send_server_notification(ServerNotification::ServerRequestResolved(
ServerRequestResolvedNotification {
thread_id,
request_id,
},
))
.await;
}
pub(super) fn merge_turn_history_with_active_turn(turns: &mut Vec<Turn>, active_turn: Turn) {
turns.retain(|turn| turn.id != active_turn.id);
turns.push(active_turn);
}
pub(super) fn set_thread_status_and_interrupt_stale_turns(
thread: &mut Thread,
loaded_status: ThreadStatus,
has_live_in_progress_turn: bool,
) {
let status = resolve_thread_status(loaded_status, has_live_in_progress_turn);
if !matches!(status, ThreadStatus::Active { .. }) {
for turn in &mut thread.turns {
if matches!(turn.status, TurnStatus::InProgress) {
turn.status = TurnStatus::Interrupted;
}
}
}
thread.status = status;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,305 @@
use super::*;
pub(super) async fn open_state_db_for_direct_thread_lookup(
config: &Config,
) -> Option<StateDbHandle> {
StateRuntime::init(config.sqlite_home.clone(), config.model_provider_id.clone())
.await
.ok()
}
pub(crate) async fn read_summary_from_rollout(
path: &Path,
fallback_provider: &str,
) -> std::io::Result<ConversationSummary> {
let head = read_head_for_summary(path).await?;
let Some(first) = head.first() else {
return Err(IoError::other(format!(
"rollout at {} is empty",
path.display()
)));
};
let session_meta_line =
serde_json::from_value::<SessionMetaLine>(first.clone()).map_err(|_| {
IoError::other(format!(
"rollout at {} does not start with session metadata",
path.display()
))
})?;
let SessionMetaLine {
meta: session_meta,
git,
} = session_meta_line;
let mut session_meta = session_meta;
session_meta.source = with_thread_spawn_agent_metadata(
session_meta.source.clone(),
session_meta.agent_nickname.clone(),
session_meta.agent_role.clone(),
);
let created_at = if session_meta.timestamp.is_empty() {
None
} else {
Some(session_meta.timestamp.as_str())
};
let updated_at = read_updated_at(path, created_at).await;
if let Some(summary) = extract_conversation_summary(
path.to_path_buf(),
&head,
&session_meta,
git.as_ref(),
fallback_provider,
updated_at.clone(),
) {
return Ok(summary);
}
let timestamp = if session_meta.timestamp.is_empty() {
None
} else {
Some(session_meta.timestamp.clone())
};
let model_provider = session_meta
.model_provider
.clone()
.unwrap_or_else(|| fallback_provider.to_string());
let git_info = git.as_ref().map(map_git_info);
let updated_at = updated_at.or_else(|| timestamp.clone());
Ok(ConversationSummary {
conversation_id: session_meta.id,
timestamp,
updated_at,
path: path.to_path_buf(),
preview: String::new(),
model_provider,
cwd: session_meta.cwd,
cli_version: session_meta.cli_version,
source: session_meta.source,
git_info,
})
}
pub(crate) async fn read_rollout_items_from_rollout(
path: &Path,
) -> std::io::Result<Vec<RolloutItem>> {
let items = match RolloutRecorder::get_rollout_history(path).await? {
InitialHistory::New | InitialHistory::Cleared => Vec::new(),
InitialHistory::Forked(items) => items,
InitialHistory::Resumed(resumed) => resumed.history,
};
Ok(items)
}
fn extract_conversation_summary(
path: PathBuf,
head: &[serde_json::Value],
session_meta: &SessionMeta,
git: Option<&CoreGitInfo>,
fallback_provider: &str,
updated_at: Option<String>,
) -> Option<ConversationSummary> {
let preview = head
.iter()
.filter_map(|value| serde_json::from_value::<ResponseItem>(value.clone()).ok())
.find_map(|item| match codex_core::parse_turn_item(&item) {
Some(TurnItem::UserMessage(user)) => Some(user.message()),
_ => None,
})?;
let preview = match preview.find(USER_MESSAGE_BEGIN) {
Some(idx) => preview[idx + USER_MESSAGE_BEGIN.len()..].trim(),
None => preview.as_str(),
};
let timestamp = if session_meta.timestamp.is_empty() {
None
} else {
Some(session_meta.timestamp.clone())
};
let conversation_id = session_meta.id;
let model_provider = session_meta
.model_provider
.clone()
.unwrap_or_else(|| fallback_provider.to_string());
let git_info = git.map(map_git_info);
let updated_at = updated_at.or_else(|| timestamp.clone());
Some(ConversationSummary {
conversation_id,
timestamp,
updated_at,
path,
preview: preview.to_string(),
model_provider,
cwd: session_meta.cwd.clone(),
cli_version: session_meta.cli_version.clone(),
source: session_meta.source.clone(),
git_info,
})
}
fn map_git_info(git_info: &CoreGitInfo) -> ConversationGitInfo {
ConversationGitInfo {
sha: git_info.commit_hash.as_ref().map(|sha| sha.0.clone()),
branch: git_info.branch.clone(),
origin_url: git_info.repository_url.clone(),
}
}
pub(super) fn with_thread_spawn_agent_metadata(
source: codex_protocol::protocol::SessionSource,
agent_nickname: Option<String>,
agent_role: Option<String>,
) -> codex_protocol::protocol::SessionSource {
if agent_nickname.is_none() && agent_role.is_none() {
return source;
}
match source {
codex_protocol::protocol::SessionSource::SubAgent(
codex_protocol::protocol::SubAgentSource::ThreadSpawn {
parent_thread_id,
depth,
agent_path,
agent_nickname: existing_agent_nickname,
agent_role: existing_agent_role,
},
) => codex_protocol::protocol::SessionSource::SubAgent(
codex_protocol::protocol::SubAgentSource::ThreadSpawn {
parent_thread_id,
depth,
agent_path,
agent_nickname: agent_nickname.or(existing_agent_nickname),
agent_role: agent_role.or(existing_agent_role),
},
),
_ => source,
}
}
pub(super) fn thread_response_active_permission_profile(
active_permission_profile: Option<codex_protocol::models::ActivePermissionProfile>,
) -> Option<codex_app_server_protocol::ActivePermissionProfile> {
active_permission_profile.map(Into::into)
}
pub(super) fn apply_permission_profile_selection_to_config_overrides(
overrides: &mut ConfigOverrides,
permissions: Option<PermissionProfileSelectionParams>,
) {
let Some(PermissionProfileSelectionParams::Profile { id, modifications }) = permissions else {
return;
};
overrides.default_permissions = Some(id);
overrides
.additional_writable_roots
.extend(modifications.unwrap_or_default().into_iter().map(
|modification| match modification {
PermissionProfileModificationParams::AdditionalWritableRoot { path } => {
path.to_path_buf()
}
},
));
}
pub(super) fn thread_response_sandbox_policy(
permission_profile: &codex_protocol::models::PermissionProfile,
cwd: &Path,
) -> codex_app_server_protocol::SandboxPolicy {
let file_system_policy = permission_profile.file_system_sandbox_policy();
let sandbox_policy = codex_sandboxing::compatibility_sandbox_policy_for_permission_profile(
permission_profile,
&file_system_policy,
permission_profile.network_sandbox_policy(),
cwd,
);
sandbox_policy.into()
}
fn parse_datetime(timestamp: Option<&str>) -> Option<DateTime<Utc>> {
timestamp.and_then(|ts| {
chrono::DateTime::parse_from_rfc3339(ts)
.ok()
.map(|dt| dt.with_timezone(&chrono::Utc))
})
}
async fn read_updated_at(path: &Path, created_at: Option<&str>) -> Option<String> {
let updated_at = tokio::fs::metadata(path)
.await
.ok()
.and_then(|meta| meta.modified().ok())
.map(|modified| {
let updated_at: DateTime<Utc> = modified.into();
updated_at.to_rfc3339_opts(SecondsFormat::Millis, true)
});
updated_at.or_else(|| created_at.map(str::to_string))
}
pub(super) fn thread_started_notification(mut thread: Thread) -> ThreadStartedNotification {
thread.turns.clear();
ThreadStartedNotification { thread }
}
pub(crate) fn summary_to_thread(
summary: ConversationSummary,
fallback_cwd: &AbsolutePathBuf,
) -> Thread {
let ConversationSummary {
conversation_id,
path,
preview,
timestamp,
updated_at,
model_provider,
cwd,
cli_version,
source,
git_info,
} = summary;
let created_at = parse_datetime(timestamp.as_deref());
let updated_at = parse_datetime(updated_at.as_deref()).or(created_at);
let git_info = git_info.map(|info| ApiGitInfo {
sha: info.sha,
branch: info.branch,
origin_url: info.origin_url,
});
let cwd =
AbsolutePathBuf::relative_to_current_dir(path_utils::normalize_for_native_workdir(cwd))
.unwrap_or_else(|err| {
warn!(
path = %path.display(),
"failed to normalize thread cwd while summarizing thread: {err}"
);
fallback_cwd.clone()
});
Thread {
id: conversation_id.to_string(),
forked_from_id: None,
preview,
ephemeral: false,
model_provider,
created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0),
updated_at: updated_at.map(|dt| dt.timestamp()).unwrap_or(0),
status: ThreadStatus::NotLoaded,
path: Some(path),
cwd,
cli_version,
agent_nickname: source.get_nickname(),
agent_role: source.get_agent_role(),
source: source.into(),
git_info,
name: None,
turns: Vec::new(),
}
}
#[cfg(test)]
#[path = "thread_summary_tests.rs"]
mod thread_summary_tests;
@@ -0,0 +1,68 @@
use super::*;
use anyhow::Result;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::path::PathBuf;
#[test]
fn extract_conversation_summary_prefers_plain_user_messages() -> Result<()> {
let conversation_id = ThreadId::from_string("3f941c35-29b3-493b-b0a4-e25800d9aeb0")?;
let timestamp = Some("2025-09-05T16:53:11.850Z".to_string());
let path = PathBuf::from("rollout.jsonl");
let head = vec![
json!({
"id": conversation_id.to_string(),
"timestamp": timestamp,
"cwd": "/",
"originator": "codex",
"cli_version": "0.0.0",
"model_provider": "test-provider"
}),
json!({
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": "# AGENTS.md instructions for project\n\n<INSTRUCTIONS>\n<AGENTS.md contents>\n</INSTRUCTIONS>".to_string(),
}],
}),
json!({
"type": "message",
"role": "user",
"content": [{
"type": "input_text",
"text": format!("<prior context> {USER_MESSAGE_BEGIN}Count to 5"),
}],
}),
];
let session_meta = serde_json::from_value::<SessionMeta>(head[0].clone())?;
let summary = extract_conversation_summary(
path.clone(),
&head,
&session_meta,
/*git*/ None,
"test-provider",
timestamp.clone(),
)
.expect("summary");
let expected = ConversationSummary {
conversation_id,
timestamp: timestamp.clone(),
updated_at: timestamp,
path,
preview: "Count to 5".to_string(),
model_provider: "test-provider".to_string(),
cwd: PathBuf::from("/"),
cli_version: "0.0.0".to_string(),
source: codex_protocol::protocol::SessionSource::VSCode,
git_info: None,
};
assert_eq!(summary, expected);
Ok(())
}
@@ -9,7 +9,6 @@
//! the time the `TokenCount` was persisted so the notification still targets the
//! corresponding rebuilt turn.
use std::path::Path;
use std::sync::Arc;
use codex_app_server_protocol::ServerNotification;
@@ -24,7 +23,6 @@ use codex_protocol::ThreadId;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::RolloutItem;
use crate::codex_message_processor::read_rollout_items_from_rollout;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::OutgoingMessageSender;
@@ -32,7 +30,7 @@ use crate::outgoing_message::OutgoingMessageSender;
///
/// This is lifecycle replay rather than a model event: the rollout already contains
/// the original `TokenCount`, and emitting through `send_event` here would duplicate
/// persisted usage records. Keeping this helper connection-scoped also avoids
/// persisted usage records. Keeping replay connection-scoped also avoids
/// surprising other subscribers with a historical usage update while they may be
/// rendering live turn events.
pub(super) async fn send_thread_token_usage_update_to_connection(
@@ -59,19 +57,6 @@ pub(super) async fn send_thread_token_usage_update_to_connection(
.await;
}
pub(super) async fn latest_token_usage_turn_id_for_thread_path(thread: &Thread) -> Option<String> {
let rollout_path = thread.path.as_deref()?;
latest_token_usage_turn_id_from_rollout_path(rollout_path, thread.turns.as_slice()).await
}
pub(super) async fn latest_token_usage_turn_id_from_rollout_path(
rollout_path: &Path,
turns: &[Turn],
) -> Option<String> {
let rollout_items = read_rollout_items_from_rollout(rollout_path).await.ok()?;
latest_token_usage_turn_id_from_rollout_items(&rollout_items, turns)
}
/// Identifies the turn that was active when a `TokenCount` record appeared.
///
/// The id is preferred when it still appears in the rebuilt thread. The position is a
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,103 @@
use super::*;
#[derive(Clone)]
pub(crate) struct WindowsSandboxRequestProcessor {
outgoing: Arc<OutgoingMessageSender>,
config: Arc<Config>,
config_manager: ConfigManager,
}
impl WindowsSandboxRequestProcessor {
pub(crate) fn new(
outgoing: Arc<OutgoingMessageSender>,
config: Arc<Config>,
config_manager: ConfigManager,
) -> Self {
Self {
outgoing,
config,
config_manager,
}
}
pub(crate) async fn windows_sandbox_setup_start(
&self,
request_id: &ConnectionRequestId,
params: WindowsSandboxSetupStartParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.windows_sandbox_setup_start_inner(request_id, params)
.await
.map(|()| None)
}
async fn windows_sandbox_setup_start_inner(
&self,
request_id: &ConnectionRequestId,
params: WindowsSandboxSetupStartParams,
) -> Result<(), JSONRPCErrorError> {
self.outgoing
.send_response(
request_id.clone(),
WindowsSandboxSetupStartResponse { started: true },
)
.await;
let mode = match params.mode {
WindowsSandboxSetupMode::Elevated => CoreWindowsSandboxSetupMode::Elevated,
WindowsSandboxSetupMode::Unelevated => CoreWindowsSandboxSetupMode::Unelevated,
};
let config = Arc::clone(&self.config);
let config_manager = self.config_manager.clone();
let command_cwd = params
.cwd
.map(PathBuf::from)
.unwrap_or_else(|| config.cwd.to_path_buf());
let outgoing = Arc::clone(&self.outgoing);
let connection_id = request_id.connection_id;
tokio::spawn(async move {
let derived_config = config_manager
.load_for_cwd(
/*request_overrides*/ None,
ConfigOverrides {
cwd: Some(command_cwd.clone()),
..Default::default()
},
Some(command_cwd.clone()),
)
.await;
let setup_result = match derived_config {
Ok(config) => {
let setup_request = WindowsSandboxSetupRequest {
mode,
policy: config
.permissions
.legacy_sandbox_policy(config.cwd.as_path()),
policy_cwd: config.cwd.to_path_buf(),
command_cwd,
env_map: std::env::vars().collect(),
codex_home: config.codex_home.to_path_buf(),
active_profile: config.active_profile.clone(),
};
codex_core::windows_sandbox::run_windows_sandbox_setup(setup_request).await
}
Err(err) => Err(err.into()),
};
let notification = WindowsSandboxSetupCompletedNotification {
mode: match mode {
CoreWindowsSandboxSetupMode::Elevated => WindowsSandboxSetupMode::Elevated,
CoreWindowsSandboxSetupMode::Unelevated => WindowsSandboxSetupMode::Unelevated,
},
success: setup_result.is_ok(),
error: setup_result.err().map(|err| err.to_string()),
};
outgoing
.send_server_notification_to_connections(
&[connection_id],
ServerNotification::WindowsSandboxSetupCompleted(notification),
)
.await;
});
Ok(())
}
}
@@ -385,7 +385,7 @@ async fn thread_resume_can_skip_turns_for_metadata_only_resume() -> Result<()> {
}
#[tokio::test]
async fn thread_resume_emits_active_goal_update_before_continuation() -> Result<()> {
async fn thread_resume_keeps_paused_goal_paused() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
@@ -477,12 +477,12 @@ async fn thread_resume_emits_active_goal_update_before_continuation() -> Result<
let ServerNotification::ThreadGoalUpdated(notification) = notification else {
anyhow::bail!("expected thread goal update notification");
};
assert_eq!(notification.goal.status, ThreadGoalStatus::Active);
assert_eq!(notification.goal.status, ThreadGoalStatus::Paused);
assert!(
!mcp.pending_notification_methods()
.iter()
.any(|method| method == "turn/started"),
"goal continuation should start only after the resume goal snapshot"
"paused goal should not continue after thread resume"
);
Ok(())
@@ -556,10 +556,15 @@ async fn run_websocket_response_stream(
trace!("websocket request: {request_text}");
let request_start = Instant::now();
let result = ws_stream
.send(Message::Text(request_text.into()))
.await
.map_err(|err| ApiError::Stream(format!("failed to send websocket request: {err}")));
let result = tokio::time::timeout(
idle_timeout,
ws_stream.send(Message::Text(request_text.into())),
)
.await
.map_err(|_| ApiError::Stream("idle timeout sending websocket request".into()))
.and_then(|result| {
result.map_err(|err| ApiError::Stream(format!("failed to send websocket request: {err}")))
});
if let Some(t) = telemetry.as_ref() {
t.on_ws_request(
+2 -1
View File
@@ -12,7 +12,7 @@ futures = { workspace = true }
http = { workspace = true }
opentelemetry = { workspace = true }
rand = { workspace = true }
reqwest = { workspace = true, features = ["json", "stream"] }
reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots", "stream"] }
rustls = { workspace = true }
rustls-native-certs = { workspace = true }
rustls-pki-types = { workspace = true }
@@ -32,5 +32,6 @@ workspace = true
codex-utils-cargo-bin = { workspace = true }
opentelemetry_sdk = { workspace = true }
pretty_assertions = { workspace = true }
rcgen = { workspace = true }
tempfile = { workspace = true }
tracing-subscriber = { workspace = true }
@@ -8,22 +8,93 @@
//! - env precedence is respected,
//! - multi-cert PEM bundles load,
//! - error messages guide users when CA files are invalid.
//! - optional HTTPS probes can complete a request through the constructed client.
//!
//! The detailed explanation of what "hermetic" means here lives in `codex_client::custom_ca`.
//! This binary exists so the tests can exercise
//! [`codex_client::build_reqwest_client_for_subprocess_tests`] in a separate process without
//! duplicating client-construction logic.
use std::env;
use std::process;
use std::time::Duration;
const PROBE_TLS13_ENV: &str = "CODEX_CUSTOM_CA_PROBE_TLS13";
const PROBE_PROXY_ENV: &str = "CODEX_CUSTOM_CA_PROBE_PROXY";
const PROBE_URL_ENV: &str = "CODEX_CUSTOM_CA_PROBE_URL";
fn main() {
match codex_client::build_reqwest_client_for_subprocess_tests(reqwest::Client::builder()) {
Ok(_) => {
println!("ok");
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => {
eprintln!("failed to create probe runtime: {error}");
process::exit(1);
}
};
match runtime.block_on(run_probe()) {
Ok(()) => println!("ok"),
Err(error) => {
eprintln!("{error}");
process::exit(1);
}
}
}
async fn run_probe() -> Result<(), String> {
let proxy_url = env::var(PROBE_PROXY_ENV).ok();
let target_url = env::var(PROBE_URL_ENV).ok();
let mut builder = reqwest::Client::builder();
if target_url.is_some() {
builder = builder.timeout(Duration::from_secs(5));
}
if env::var_os(PROBE_TLS13_ENV).is_some() {
builder = builder.min_tls_version(reqwest::tls::Version::TLS_1_3);
}
let client = build_probe_client(builder, proxy_url.as_deref())?;
if let Some(url) = target_url {
post_probe_request(&client, &url).await?;
}
Ok(())
}
fn build_probe_client(
builder: reqwest::ClientBuilder,
proxy_url: Option<&str>,
) -> Result<reqwest::Client, String> {
if let Some(proxy_url) = proxy_url {
let proxy = reqwest::Proxy::https(proxy_url)
.map_err(|error| format!("failed to configure probe proxy {proxy_url}: {error}"))?;
return codex_client::build_reqwest_client_with_custom_ca(builder.proxy(proxy))
.map_err(|error| error.to_string());
}
codex_client::build_reqwest_client_for_subprocess_tests(builder)
.map_err(|error| error.to_string())
}
async fn post_probe_request(client: &reqwest::Client, url: &str) -> Result<(), String> {
let response = client
.post(url)
.header("Content-Type", "application/x-www-form-urlencoded")
.body("grant_type=authorization_code&code=test")
.send()
.await
.map_err(|error| format!("probe request failed: {error:?}"))?;
let status = response.status();
let body = response
.text()
.await
.map_err(|error| format!("failed to read probe response body: {error}"))?;
if !status.is_success() {
return Err(format!("probe request returned {status}: {body}"));
}
if body != "ok" {
return Err(format!("probe response body mismatch: {body}"));
}
Ok(())
}
+15 -6
View File
@@ -14,10 +14,9 @@
//! `TRUSTED CERTIFICATE` labels and bundles that also contain CRLs
//! - return user-facing errors that explain how to fix misconfigured CA files
//!
//! It does not validate certificate chains or perform a handshake in tests. Its contract is
//! narrower: produce a transport configuration whose root store contains every parseable
//! certificate block from the configured PEM bundle, or fail early with a precise error before
//! the caller starts network traffic.
//! Its production contract is narrow: produce a transport configuration whose root store contains
//! every parseable certificate block from the configured PEM bundle, or fail early with a precise
//! error before the caller starts network traffic.
//!
//! In this module's test setup, a hermetic test is one whose result depends only on the CA file
//! and environment variables that the test chose for itself. That matters here because the normal
@@ -36,7 +35,8 @@
//! - unit tests in this module cover env-selection logic without constructing a real client
//! - subprocess integration tests under `tests/` cover real client construction through
//! [`build_reqwest_client_for_subprocess_tests`], which disables reqwest proxy autodetection so
//! the tests can observe custom-CA success and failure directly
//! the tests can observe custom-CA success and failure directly, including one TLS handshake
//! through a local HTTPS server
//! - those subprocess tests also scrub inherited CA environment variables before launch so their
//! result depends only on the test fixtures and env vars set by the test itself
@@ -266,12 +266,21 @@ fn maybe_build_rustls_client_config_with_env(
/// This exists so tests can exercise precedence behavior deterministically without mutating the
/// real process environment. It selects the CA bundle, delegates file parsing to
/// [`ConfiguredCaBundle::load_certificates`], preserves the caller's chosen `reqwest` builder
/// configuration, and finally registers each parsed certificate with that builder.
/// configuration, forces rustls when a custom CA is configured, and finally registers each parsed
/// certificate with that builder.
fn build_reqwest_client_with_env(
env_source: &dyn EnvSource,
mut builder: reqwest::ClientBuilder,
) -> Result<reqwest::Client, BuildCustomCaTransportError> {
if let Some(bundle) = env_source.configured_ca_bundle() {
ensure_rustls_crypto_provider();
info!(
source_env = bundle.source_env,
ca_path = %bundle.path.display(),
"building HTTP client with rustls backend for custom CA bundle"
);
builder = builder.use_rustls_tls();
let certificates = bundle.load_certificates()?;
for (idx, cert) in certificates.iter().enumerate() {
+408 -5
View File
@@ -4,24 +4,83 @@
//! `build_reqwest_client_for_subprocess_tests` instead of calling the helper in-process. The
//! detailed explanation of what "hermetic" means here lives in `codex_client::custom_ca`; these
//! tests add the process-level half of that contract by scrubbing inherited CA environment
//! variables before each subprocess launch. They still stop at client construction: the
//! assertions here cover CA file selection, PEM parsing, and user-facing errors, not a full TLS
//! handshake.
//! variables before each subprocess launch. Most assertions here cover CA file selection, PEM
//! parsing, and user-facing errors. The HTTPS probes go further and perform real POSTs against
//! locally generated certificates, including through a TLS-intercepting CONNECT proxy.
use codex_utils_cargo_bin::cargo_bin;
use rcgen::BasicConstraints;
use rcgen::CertificateParams;
use rcgen::CertifiedIssuer;
use rcgen::DistinguishedName;
use rcgen::DnType;
use rcgen::ExtendedKeyUsagePurpose;
use rcgen::IsCa;
use rcgen::KeyPair;
use rcgen::KeyUsagePurpose;
use rcgen::PKCS_ECDSA_P256_SHA256;
use rustls_pki_types::CertificateDer;
use rustls_pki_types::PrivateKeyDer;
use std::fs;
use std::io;
use std::io::Read;
use std::io::Write;
use std::net::TcpListener;
use std::net::TcpStream;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::sync::Arc;
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use std::time::Instant;
use tempfile::TempDir;
const CODEX_CA_CERT_ENV: &str = "CODEX_CA_CERTIFICATE";
const PROBE_PROXY_ENV: &str = "CODEX_CUSTOM_CA_PROBE_PROXY";
const PROBE_TLS13_ENV: &str = "CODEX_CUSTOM_CA_PROBE_TLS13";
const PROBE_URL_ENV: &str = "CODEX_CUSTOM_CA_PROBE_URL";
const SSL_CERT_FILE_ENV: &str = "SSL_CERT_FILE";
const PROXY_ENV_VARS: &[&str] = &[
"HTTP_PROXY",
"http_proxy",
"HTTPS_PROXY",
"https_proxy",
"ALL_PROXY",
"all_proxy",
"NO_PROXY",
"no_proxy",
];
const TEST_CERT_1: &str = include_str!("fixtures/test-ca.pem");
const TEST_CERT_2: &str = include_str!("fixtures/test-intermediate.pem");
const TRUSTED_TEST_CERT: &str = include_str!("fixtures/test-ca-trusted.pem");
fn write_cert_file(temp_dir: &TempDir, name: &str, contents: &str) -> std::path::PathBuf {
struct Tls13Material {
ca_cert_pem: String,
server_cert: CertificateDer<'static>,
server_key: PrivateKeyDer<'static>,
}
struct Tls13TestServer {
ca_cert_pem: String,
request_rx: mpsc::Receiver<Result<String, String>>,
url: String,
}
struct PlainHttpOrigin {
request_rx: mpsc::Receiver<Result<String, String>>,
url: String,
}
struct TlsInterceptingProxy {
ca_cert_pem: String,
request_rx: mpsc::Receiver<Result<String, String>>,
url: String,
}
fn write_cert_file(temp_dir: &TempDir, name: &str, contents: &str) -> PathBuf {
let path = temp_dir.path().join(name);
fs::write(&path, contents).unwrap_or_else(|error| {
panic!("write cert fixture failed for {}: {error}", path.display())
@@ -29,7 +88,7 @@ fn write_cert_file(temp_dir: &TempDir, name: &str, contents: &str) -> std::path:
path
}
fn run_probe(envs: &[(&str, &Path)]) -> std::process::Output {
fn probe_command() -> Command {
let mut cmd = Command::new(
cargo_bin("custom_ca_probe")
.unwrap_or_else(|error| panic!("failed to locate custom_ca_probe: {error}")),
@@ -37,7 +96,18 @@ fn run_probe(envs: &[(&str, &Path)]) -> std::process::Output {
// `Command` inherits the parent environment by default, so scrub CA-related variables first or
// these tests can accidentally pass/fail based on the developer shell or CI runner.
cmd.env_remove(CODEX_CA_CERT_ENV);
cmd.env_remove(PROBE_PROXY_ENV);
cmd.env_remove(PROBE_TLS13_ENV);
cmd.env_remove(PROBE_URL_ENV);
cmd.env_remove(SSL_CERT_FILE_ENV);
for env_var in PROXY_ENV_VARS {
cmd.env_remove(env_var);
}
cmd
}
fn run_probe(envs: &[(&str, &Path)]) -> std::process::Output {
let mut cmd = probe_command();
for (key, value) in envs {
cmd.env(key, value);
}
@@ -45,6 +115,286 @@ fn run_probe(envs: &[(&str, &Path)]) -> std::process::Output {
.unwrap_or_else(|error| panic!("failed to run custom_ca_probe: {error}"))
}
fn run_probe_posting_to_tls13_server(envs: &[(&str, &Path)], url: &str) -> std::process::Output {
let mut cmd = probe_command();
for (key, value) in envs {
cmd.env(key, value);
}
cmd.env(PROBE_TLS13_ENV, "1");
cmd.env(PROBE_URL_ENV, url);
cmd.output()
.unwrap_or_else(|error| panic!("failed to run custom_ca_probe: {error}"))
}
fn run_probe_posting_through_tls_intercepting_proxy(
envs: &[(&str, &Path)],
url: &str,
proxy_url: &str,
) -> std::process::Output {
let mut cmd = probe_command();
for (key, value) in envs {
cmd.env(key, value);
}
cmd.env(PROBE_PROXY_ENV, proxy_url);
cmd.env(PROBE_TLS13_ENV, "1");
cmd.env(PROBE_URL_ENV, url);
cmd.output()
.unwrap_or_else(|error| panic!("failed to run custom_ca_probe: {error}"))
}
fn spawn_tls13_test_server() -> Tls13TestServer {
codex_utils_rustls_provider::ensure_rustls_crypto_provider();
let material = generate_tls13_material();
let listener = TcpListener::bind(("127.0.0.1", 0))
.unwrap_or_else(|error| panic!("bind TLS test server: {error}"));
listener
.set_nonblocking(true)
.unwrap_or_else(|error| panic!("set TLS test server nonblocking: {error}"));
let port = listener
.local_addr()
.unwrap_or_else(|error| panic!("TLS test server addr: {error}"))
.port();
let config = Arc::new(
rustls::ServerConfig::builder_with_protocol_versions(&[&rustls::version::TLS13])
.with_no_client_auth()
.with_single_cert(vec![material.server_cert], material.server_key)
.unwrap_or_else(|error| panic!("TLS 1.3 server config: {error}")),
);
let (request_tx, request_rx) = mpsc::channel();
thread::spawn(move || {
let result = accept_tls13_request(listener, config);
let _ = request_tx.send(result.map_err(|error| error.to_string()));
});
Tls13TestServer {
ca_cert_pem: material.ca_cert_pem,
request_rx,
url: format!("https://127.0.0.1:{port}/oauth/token"),
}
}
fn spawn_plain_http_origin() -> PlainHttpOrigin {
let listener = TcpListener::bind(("127.0.0.1", 0))
.unwrap_or_else(|error| panic!("bind plain HTTP origin: {error}"));
listener
.set_nonblocking(true)
.unwrap_or_else(|error| panic!("set plain HTTP origin nonblocking: {error}"));
let port = listener
.local_addr()
.unwrap_or_else(|error| panic!("plain HTTP origin addr: {error}"))
.port();
let (request_tx, request_rx) = mpsc::channel();
thread::spawn(move || {
let result = accept_plain_http_origin_request(listener);
let _ = request_tx.send(result.map_err(|error| error.to_string()));
});
PlainHttpOrigin {
request_rx,
url: format!("https://127.0.0.1:{port}/oauth/token"),
}
}
fn spawn_tls_intercepting_proxy() -> TlsInterceptingProxy {
codex_utils_rustls_provider::ensure_rustls_crypto_provider();
let material = generate_tls13_material();
let listener = TcpListener::bind(("127.0.0.1", 0))
.unwrap_or_else(|error| panic!("bind TLS intercepting proxy: {error}"));
listener
.set_nonblocking(true)
.unwrap_or_else(|error| panic!("set TLS intercepting proxy nonblocking: {error}"));
let port = listener
.local_addr()
.unwrap_or_else(|error| panic!("TLS intercepting proxy addr: {error}"))
.port();
let config = Arc::new(
rustls::ServerConfig::builder_with_protocol_versions(&[&rustls::version::TLS13])
.with_no_client_auth()
.with_single_cert(vec![material.server_cert], material.server_key)
.unwrap_or_else(|error| panic!("TLS intercepting proxy config: {error}")),
);
let (request_tx, request_rx) = mpsc::channel();
thread::spawn(move || {
let result = accept_tls_intercepting_proxy_request(listener, config);
let _ = request_tx.send(result.map_err(|error| error.to_string()));
});
TlsInterceptingProxy {
ca_cert_pem: material.ca_cert_pem,
request_rx,
url: format!("http://127.0.0.1:{port}"),
}
}
fn generate_tls13_material() -> Tls13Material {
let mut ca_params = CertificateParams::default();
ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
ca_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
let mut ca_distinguished_name = DistinguishedName::new();
ca_distinguished_name.push(DnType::CommonName, "codex test CA");
ca_params.distinguished_name = ca_distinguished_name;
let ca_key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)
.unwrap_or_else(|error| panic!("generate test CA key pair: {error}"));
let ca = CertifiedIssuer::self_signed(ca_params, ca_key_pair)
.unwrap_or_else(|error| panic!("generate test CA certificate: {error}"));
let mut server_params =
CertificateParams::new(vec!["localhost".to_string(), "127.0.0.1".to_string()])
.unwrap_or_else(|error| panic!("create test server certificate params: {error}"));
server_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
server_params.key_usages = vec![
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyEncipherment,
];
let server_key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256)
.unwrap_or_else(|error| panic!("generate test server key pair: {error}"));
let server_cert = server_params
.signed_by(&server_key_pair, &ca)
.unwrap_or_else(|error| panic!("generate test server certificate: {error}"));
Tls13Material {
ca_cert_pem: ca.pem(),
server_cert: server_cert.der().clone(),
server_key: PrivateKeyDer::from(server_key_pair),
}
}
fn accept_plain_http_origin_request(listener: TcpListener) -> io::Result<String> {
let mut stream = accept_with_timeout(listener, Duration::from_secs(5))?;
stream.set_nonblocking(false)?;
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
let request = read_http_message(&mut stream)?;
stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")?;
stream.flush()?;
Ok(request)
}
fn accept_tls13_request(
listener: TcpListener,
config: Arc<rustls::ServerConfig>,
) -> io::Result<String> {
let stream = accept_with_timeout(listener, Duration::from_secs(5))?;
stream.set_nonblocking(false)?;
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
let connection = rustls::ServerConnection::new(config).map_err(io::Error::other)?;
let mut tls = rustls::StreamOwned::new(connection, stream);
let request = read_http_message(&mut tls)?;
tls.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")?;
tls.flush()?;
Ok(request)
}
fn accept_tls_intercepting_proxy_request(
listener: TcpListener,
config: Arc<rustls::ServerConfig>,
) -> io::Result<String> {
let mut stream = accept_with_timeout(listener, Duration::from_secs(5))?;
stream.set_nonblocking(false)?;
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
let connect_request = read_http_message(&mut stream)?;
let origin_authority = connect_authority_from_request(&connect_request)?;
stream.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")?;
stream.flush()?;
let connection = rustls::ServerConnection::new(config).map_err(io::Error::other)?;
let mut tls = rustls::StreamOwned::new(connection, stream);
let request = read_http_message(&mut tls)?;
let mut origin = TcpStream::connect(origin_authority.as_str())?;
origin.set_read_timeout(Some(Duration::from_secs(5)))?;
origin.set_write_timeout(Some(Duration::from_secs(5)))?;
origin.write_all(request.as_bytes())?;
origin.flush()?;
let response = read_http_message(&mut origin)?;
tls.write_all(response.as_bytes())?;
tls.flush()?;
Ok(request)
}
fn connect_authority_from_request(request: &str) -> io::Result<String> {
let request_line = request
.lines()
.next()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "empty CONNECT request"))?;
let mut parts = request_line.split_whitespace();
match (parts.next(), parts.next(), parts.next()) {
(Some("CONNECT"), Some(authority), Some(_version)) => Ok(authority.to_string()),
_ => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid CONNECT request line: {request_line}"),
)),
}
}
fn accept_with_timeout(listener: TcpListener, timeout: Duration) -> io::Result<TcpStream> {
let deadline = Instant::now() + timeout;
loop {
match listener.accept() {
Ok((stream, _)) => return Ok(stream),
Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
if Instant::now() >= deadline {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"timed out waiting for TLS test client",
));
}
thread::sleep(Duration::from_millis(10));
}
Err(error) => return Err(error),
}
}
}
fn read_http_message(stream: &mut impl Read) -> io::Result<String> {
let mut buffer = Vec::new();
let mut chunk = [0; 1024];
loop {
let bytes_read = stream.read(&mut chunk)?;
if bytes_read == 0 {
break;
}
buffer.extend_from_slice(&chunk[..bytes_read]);
if let Some(header_end) = buffer.windows(4).position(|window| window == b"\r\n\r\n") {
let body_start = header_end + 4;
let headers = String::from_utf8_lossy(&buffer[..body_start]);
let content_length = headers
.lines()
.filter_map(|line| line.split_once(':'))
.find_map(|(name, value)| {
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().ok())
.flatten()
})
.unwrap_or(0);
if buffer.len() >= body_start + content_length {
break;
}
}
}
Ok(String::from_utf8_lossy(&buffer).into_owned())
}
fn assert_token_exchange_request(request: &str) {
assert!(
request.starts_with("POST /oauth/token HTTP/1.1"),
"unexpected request:\n{request}"
);
assert!(
request.contains("grant_type=authorization_code&code=test"),
"unexpected request body:\n{request}"
);
}
#[test]
fn uses_codex_ca_cert_env() {
let temp_dir = TempDir::new().expect("tempdir");
@@ -90,6 +440,59 @@ fn handles_multi_certificate_bundle() {
assert!(output.status.success());
}
#[test]
fn posts_to_tls13_server_using_custom_ca_bundle() {
let temp_dir = TempDir::new().expect("tempdir");
let server = spawn_tls13_test_server();
let cert_path = write_cert_file(&temp_dir, "tls-ca.pem", &server.ca_cert_pem);
let output =
run_probe_posting_to_tls13_server(&[(CODEX_CA_CERT_ENV, cert_path.as_path())], &server.url);
let server_result = server.request_rx.recv_timeout(Duration::from_secs(5));
assert!(
output.status.success(),
"custom_ca_probe failed\nstdout:\n{}\nstderr:\n{}\nserver:\n{server_result:?}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let request = server_result
.expect("TLS test server should report a request")
.expect("TLS test server should accept the probe request");
assert_token_exchange_request(&request);
}
#[test]
fn posts_to_token_origin_through_tls_intercepting_proxy_with_custom_ca_bundle() {
let temp_dir = TempDir::new().expect("tempdir");
let origin = spawn_plain_http_origin();
let proxy = spawn_tls_intercepting_proxy();
let cert_path = write_cert_file(&temp_dir, "proxy-ca.pem", &proxy.ca_cert_pem);
let output = run_probe_posting_through_tls_intercepting_proxy(
&[(CODEX_CA_CERT_ENV, cert_path.as_path())],
&origin.url,
&proxy.url,
);
let proxy_result = proxy.request_rx.recv_timeout(Duration::from_secs(5));
let origin_result = origin.request_rx.recv_timeout(Duration::from_secs(5));
assert!(
output.status.success(),
"custom_ca_probe failed\nstdout:\n{}\nstderr:\n{}\nproxy:\n{proxy_result:?}\norigin:\n{origin_result:?}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let proxy_request = proxy_result
.expect("TLS intercepting proxy should report a request")
.expect("TLS intercepting proxy should accept the probe request");
let origin_request = origin_result
.expect("plain HTTP origin should report a request")
.expect("plain HTTP origin should accept the forwarded request");
assert_token_exchange_request(&proxy_request);
assert_token_exchange_request(&origin_request);
}
#[test]
fn rejects_empty_pem_file_with_hint() {
let temp_dir = TempDir::new().expect("tempdir");
+6
View File
@@ -496,6 +496,9 @@
"realtime_conversation": {
"type": "boolean"
},
"remote_compaction_v2": {
"type": "boolean"
},
"remote_control": {
"type": "boolean"
},
@@ -3962,6 +3965,9 @@
"realtime_conversation": {
"type": "boolean"
},
"remote_compaction_v2": {
"type": "boolean"
},
"remote_control": {
"type": "boolean"
},
+1
View File
@@ -115,6 +115,7 @@ fn keep_forked_rollout_item(item: &RolloutItem) -> bool {
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::Compaction { .. }
| ResponseItem::ContextCompaction { .. }
| ResponseItem::Other,
) => false,
// A forked child gets its own runtime config, including spawned-agent
+1
View File
@@ -384,6 +384,7 @@ fn build_arc_monitor_message_item(
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::Compaction { .. }
| ResponseItem::ContextCompaction { .. }
| ResponseItem::Other => None,
}
}
+88 -101
View File
@@ -433,36 +433,33 @@ impl ModelClient {
RequestRouteTelemetry::for_endpoint(RESPONSES_COMPACT_ENDPOINT),
self.state.auth_env_telemetry.clone(),
);
let request = self.build_responses_request(
&client_setup.api_provider,
prompt,
model_info,
effort,
summary,
/*service_tier*/ None,
)?;
let ResponsesApiRequest {
model,
instructions,
input,
tools,
parallel_tool_calls,
reasoning,
text,
..
} = request;
let client =
ApiCompactClient::new(transport, client_setup.api_provider, client_setup.api_auth)
.with_telemetry(Some(request_telemetry));
let instructions = prompt.base_instructions.text.clone();
let input = prompt.get_formatted_input();
let tools = create_tools_json_for_responses_api(&prompt.tools)?;
let reasoning = Self::build_reasoning(model_info, effort, summary);
let verbosity = if model_info.support_verbosity {
self.state.model_verbosity.or(model_info.default_verbosity)
} else {
if self.state.model_verbosity.is_some() {
warn!(
"model_verbosity is set but ignored as the model does not support verbosity: {}",
model_info.slug
);
}
None
};
let text = create_text_param_for_request(
verbosity,
&prompt.output_schema,
prompt.output_schema_strict,
);
let payload = ApiCompactionInput {
model: &model_info.slug,
model: &model,
input: &input,
instructions: &instructions,
tools,
parallel_tool_calls: prompt.parallel_tool_calls,
parallel_tool_calls,
reasoning,
text,
};
@@ -471,6 +468,11 @@ impl ModelClient {
if let Ok(header_value) = HeaderValue::from_str(&self.state.installation_id) {
extra_headers.insert(X_CODEX_INSTALLATION_ID_HEADER, header_value);
}
extra_headers.extend(build_responses_headers(
self.state.beta_features_header.as_deref(),
/*turn_state*/ None,
/*turn_metadata_header*/ None,
));
extra_headers.extend(self.build_responses_identity_headers());
extra_headers.extend(build_conversation_headers(Some(
self.state.conversation_id.to_string(),
@@ -659,6 +661,67 @@ impl ModelClient {
}
}
fn build_responses_request(
&self,
provider: &codex_api::Provider,
prompt: &Prompt,
model_info: &ModelInfo,
effort: Option<ReasoningEffortConfig>,
summary: ReasoningSummaryConfig,
service_tier: Option<ServiceTier>,
) -> Result<ResponsesApiRequest> {
let instructions = &prompt.base_instructions.text;
let input = prompt.get_formatted_input();
let tools = create_tools_json_for_responses_api(&prompt.tools)?;
let reasoning = Self::build_reasoning(model_info, effort, summary);
let include = if reasoning.is_some() {
vec!["reasoning.encrypted_content".to_string()]
} else {
Vec::new()
};
let verbosity = if model_info.support_verbosity {
self.state.model_verbosity.or(model_info.default_verbosity)
} else {
if self.state.model_verbosity.is_some() {
warn!(
"model_verbosity is set but ignored as the model does not support verbosity: {}",
model_info.slug
);
}
None
};
let text = create_text_param_for_request(
verbosity,
&prompt.output_schema,
prompt.output_schema_strict,
);
let prompt_cache_key = Some(self.state.conversation_id.to_string());
let request = ResponsesApiRequest {
model: model_info.slug.clone(),
instructions: instructions.clone(),
input,
tools,
tool_choice: "auto".to_string(),
parallel_tool_calls: prompt.parallel_tool_calls,
reasoning,
store: provider.is_azure_responses_endpoint(),
stream: true,
include,
service_tier: match service_tier {
Some(ServiceTier::Fast) => Some("priority".to_string()),
Some(service_tier) => Some(service_tier.to_string()),
None => None,
},
prompt_cache_key,
text,
client_metadata: Some(HashMap::from([(
X_CODEX_INSTALLATION_ID_HEADER.to_string(),
self.state.installation_id.clone(),
)])),
};
Ok(request)
}
/// Returns whether the Responses-over-WebSocket transport is active for this session.
///
/// WebSocket use is controlled by provider capability and session-scoped fallback state.
@@ -828,82 +891,6 @@ impl ModelClientSession {
.set_connection_reused(/*connection_reused*/ false);
}
fn build_responses_request(
&self,
provider: &codex_api::Provider,
prompt: &Prompt,
model_info: &ModelInfo,
effort: Option<ReasoningEffortConfig>,
summary: ReasoningSummaryConfig,
service_tier: Option<ServiceTier>,
) -> Result<ResponsesApiRequest> {
let instructions = &prompt.base_instructions.text;
let input = prompt.get_formatted_input();
let tools = create_tools_json_for_responses_api(&prompt.tools)?;
let default_reasoning_effort = model_info.default_reasoning_level;
let reasoning = if model_info.supports_reasoning_summaries {
Some(Reasoning {
effort: effort.or(default_reasoning_effort),
summary: if summary == ReasoningSummaryConfig::None {
None
} else {
Some(summary)
},
})
} else {
None
};
let include = if reasoning.is_some() {
vec!["reasoning.encrypted_content".to_string()]
} else {
Vec::new()
};
let verbosity = if model_info.support_verbosity {
self.client
.state
.model_verbosity
.or(model_info.default_verbosity)
} else {
if self.client.state.model_verbosity.is_some() {
warn!(
"model_verbosity is set but ignored as the model does not support verbosity: {}",
model_info.slug
);
}
None
};
let text = create_text_param_for_request(
verbosity,
&prompt.output_schema,
prompt.output_schema_strict,
);
let prompt_cache_key = Some(self.client.state.conversation_id.to_string());
let request = ResponsesApiRequest {
model: model_info.slug.clone(),
instructions: instructions.clone(),
input,
tools,
tool_choice: "auto".to_string(),
parallel_tool_calls: prompt.parallel_tool_calls,
reasoning,
store: provider.is_azure_responses_endpoint(),
stream: true,
include,
service_tier: match service_tier {
Some(ServiceTier::Fast) => Some("priority".to_string()),
Some(service_tier) => Some(service_tier.to_string()),
None => None,
},
prompt_cache_key,
text,
client_metadata: Some(HashMap::from([(
X_CODEX_INSTALLATION_ID_HEADER.to_string(),
self.client.state.installation_id.clone(),
)])),
};
Ok(request)
}
#[allow(clippy::too_many_arguments)]
/// Builds shared Responses API transport options and request-body options.
///
@@ -1204,7 +1191,7 @@ impl ModelClientSession {
let compression = self.responses_request_compression(client_setup.auth.as_ref());
let options = self.build_responses_options(turn_metadata_header, compression);
let request = self.build_responses_request(
let request = self.client.build_responses_request(
&client_setup.api_provider,
prompt,
model_info,
@@ -1310,7 +1297,7 @@ impl ModelClientSession {
let compression = self.responses_request_compression(client_setup.auth.as_ref());
let options = self.build_responses_options(turn_metadata_header, compression);
let request = self.build_responses_request(
let request = self.client.build_responses_request(
&client_setup.api_provider,
prompt,
model_info,
+7 -1
View File
@@ -420,7 +420,13 @@ pub(crate) fn insert_initial_context_before_last_real_user_or_summary(
.iter()
.enumerate()
.rev()
.find_map(|(i, item)| matches!(item, ResponseItem::Compaction { .. }).then_some(i));
.find_map(|(i, item)| {
matches!(
item,
ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. }
)
.then_some(i)
});
let insertion_index = last_real_user_index
.or(last_user_or_summary_index)
.or(last_compaction_index);
+5 -5
View File
@@ -268,7 +268,7 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool {
}
ResponseItem::Message { role, .. } if role == "assistant" => true,
ResponseItem::Message { .. } => false,
ResponseItem::Compaction { .. } => true,
ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true,
ResponseItem::Reasoning { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
@@ -284,11 +284,11 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool {
}
#[derive(Debug)]
struct CompactRequestLogData {
pub(crate) struct CompactRequestLogData {
failing_compaction_request_model_visible_bytes: i64,
}
fn build_compact_request_log_data(
pub(crate) fn build_compact_request_log_data(
input: &[ResponseItem],
instructions: &str,
) -> CompactRequestLogData {
@@ -305,7 +305,7 @@ fn build_compact_request_log_data(
}
}
fn log_remote_compact_failure(
pub(crate) fn log_remote_compact_failure(
turn_context: &TurnContext,
log_data: &CompactRequestLogData,
total_usage_breakdown: TotalTokenUsageBreakdown,
@@ -324,7 +324,7 @@ fn log_remote_compact_failure(
);
}
fn trim_function_call_history_to_fit_context_window(
pub(crate) fn trim_function_call_history_to_fit_context_window(
history: &mut ContextManager,
turn_context: &TurnContext,
base_instructions: &BaseInstructions,
+448
View File
@@ -0,0 +1,448 @@
use std::collections::HashSet;
use std::sync::Arc;
use crate::Prompt;
use crate::ResponseStream;
use crate::client::ModelClientSession;
use crate::client_common::ResponseEvent;
use crate::compact::CompactionAnalyticsAttempt;
use crate::compact::InitialContextInjection;
use crate::compact::compaction_status_from_result;
use crate::compact_remote::build_compact_request_log_data;
use crate::compact_remote::log_remote_compact_failure;
use crate::compact_remote::process_compacted_history;
use crate::compact_remote::trim_function_call_history_to_fit_context_window;
use crate::session::session::Session;
use crate::session::turn::built_tools;
use crate::session::turn_context::TurnContext;
use codex_analytics::CompactionImplementation;
use codex_analytics::CompactionPhase;
use codex_analytics::CompactionReason;
use codex_analytics::CompactionTrigger;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::items::ContextCompactionItem;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::CompactedItem;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::TurnStartedEvent;
use codex_rollout_trace::CompactionCheckpointTracePayload;
use codex_rollout_trace::InferenceTraceContext;
use futures::StreamExt;
use futures::TryFutureExt;
use tokio_util::sync::CancellationToken;
use tracing::info;
pub(crate) async fn run_inline_remote_auto_compact_task(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
client_session: &mut ModelClientSession,
initial_context_injection: InitialContextInjection,
reason: CompactionReason,
phase: CompactionPhase,
) -> CodexResult<()> {
run_remote_compact_task_inner(
&sess,
&turn_context,
Some(client_session),
initial_context_injection,
CompactionTrigger::Auto,
reason,
phase,
)
.await
}
pub(crate) async fn run_remote_compact_task(
sess: Arc<Session>,
turn_context: Arc<TurnContext>,
) -> CodexResult<()> {
let start_event = EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_context.sub_id.clone(),
started_at: turn_context.turn_timing_state.started_at_unix_secs().await,
model_context_window: turn_context.model_context_window(),
collaboration_mode_kind: turn_context.collaboration_mode.mode,
});
sess.send_event(&turn_context, start_event).await;
run_remote_compact_task_inner(
&sess,
&turn_context,
/*client_session*/ None,
InitialContextInjection::DoNotInject,
CompactionTrigger::Manual,
CompactionReason::UserRequested,
CompactionPhase::StandaloneTurn,
)
.await
}
async fn run_remote_compact_task_inner(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
client_session: Option<&mut ModelClientSession>,
initial_context_injection: InitialContextInjection,
trigger: CompactionTrigger,
reason: CompactionReason,
phase: CompactionPhase,
) -> CodexResult<()> {
let attempt = CompactionAnalyticsAttempt::begin(
sess.as_ref(),
turn_context.as_ref(),
trigger,
reason,
CompactionImplementation::Responses,
phase,
)
.await;
let result = run_remote_compact_task_inner_impl(
sess,
turn_context,
client_session,
initial_context_injection,
)
.await;
attempt
.track(
sess.as_ref(),
compaction_status_from_result(&result),
result.as_ref().err().map(ToString::to_string),
)
.await;
if let Err(err) = result {
let event = EventMsg::Error(
err.to_error_event(Some("Error running remote compact task".to_string())),
);
sess.send_event(turn_context, event).await;
return Err(err);
}
Ok(())
}
async fn run_remote_compact_task_inner_impl(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
client_session: Option<&mut ModelClientSession>,
initial_context_injection: InitialContextInjection,
) -> CodexResult<()> {
let context_compaction_item = ContextCompactionItem::new();
let compaction_trace = sess.services.rollout_thread_trace.compaction_trace_context(
turn_context.sub_id.as_str(),
context_compaction_item.id.as_str(),
turn_context.model_info.slug.as_str(),
turn_context.provider.info().name.as_str(),
);
let compaction_item = TurnItem::ContextCompaction(context_compaction_item);
sess.emit_turn_item_started(turn_context, &compaction_item)
.await;
let mut history = sess.clone_history().await;
let base_instructions = sess.get_base_instructions().await;
let deleted_items = trim_function_call_history_to_fit_context_window(
&mut history,
turn_context.as_ref(),
&base_instructions,
);
if deleted_items > 0 {
info!(
turn_id = %turn_context.sub_id,
deleted_items,
"trimmed history items before remote compaction v2"
);
}
let trace_input_history = history.raw_items().to_vec();
let prompt_input = history.for_prompt(&turn_context.model_info.input_modalities);
let tool_router = built_tools(
sess.as_ref(),
turn_context.as_ref(),
&prompt_input,
&HashSet::new(),
/*skills_outcome*/ None,
&CancellationToken::new(),
)
.await?;
let mut input = prompt_input.clone();
input.push(ResponseItem::ContextCompaction {
encrypted_content: None,
});
let prompt = Prompt {
input,
tools: tool_router.model_visible_specs(),
parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls,
base_instructions,
personality: turn_context.personality,
output_schema: None,
output_schema_strict: true,
};
let turn_metadata_header = turn_context.turn_metadata_state.current_header_value();
let trace_attempt = compaction_trace.start_attempt(&serde_json::json!({
"model": turn_context.model_info.slug.as_str(),
"instructions": prompt.base_instructions.text.as_str(),
"input": &prompt.input,
"parallel_tool_calls": prompt.parallel_tool_calls,
}));
let compaction_output_result = if let Some(client_session) = client_session {
run_remote_compaction_request_v2(
sess,
turn_context,
client_session,
&prompt,
turn_metadata_header.as_deref(),
)
.await
} else {
let mut owned_client_session = sess.services.model_client.new_session();
run_remote_compaction_request_v2(
sess,
turn_context,
&mut owned_client_session,
&prompt,
turn_metadata_header.as_deref(),
)
.await
};
trace_attempt.record_result(compaction_output_result.as_ref().map(std::slice::from_ref));
let compaction_output = compaction_output_result?;
let compacted_history = build_v2_compacted_history(&prompt_input, compaction_output);
let new_history = process_compacted_history(
sess.as_ref(),
turn_context.as_ref(),
compacted_history,
initial_context_injection,
)
.await;
let reference_context_item = match initial_context_injection {
InitialContextInjection::DoNotInject => None,
InitialContextInjection::BeforeLastUserMessage => Some(turn_context.to_turn_context_item()),
};
let compacted_item = CompactedItem {
message: String::new(),
replacement_history: Some(new_history.clone()),
};
compaction_trace.record_installed(&CompactionCheckpointTracePayload {
input_history: &trace_input_history,
replacement_history: &new_history,
});
sess.replace_compacted_history(new_history, reference_context_item, compacted_item)
.await;
sess.recompute_token_usage(turn_context).await;
sess.emit_turn_item_completed(turn_context, compaction_item)
.await;
Ok(())
}
async fn run_remote_compaction_request_v2(
sess: &Session,
turn_context: &TurnContext,
client_session: &mut ModelClientSession,
prompt: &Prompt,
turn_metadata_header: Option<&str>,
) -> CodexResult<ResponseItem> {
let stream = client_session
.stream(
prompt,
&turn_context.model_info,
&turn_context.session_telemetry,
turn_context.reasoning_effort,
turn_context.reasoning_summary,
turn_context.config.service_tier,
turn_metadata_header,
&InferenceTraceContext::disabled(),
)
.or_else(|err| async {
let total_usage_breakdown = sess.get_total_token_usage_breakdown().await;
let compact_request_log_data =
build_compact_request_log_data(&prompt.input, &prompt.base_instructions.text);
log_remote_compact_failure(
turn_context,
&compact_request_log_data,
total_usage_breakdown,
&err,
);
Err(err)
})
.await?;
collect_context_compaction_output(stream).await
}
async fn collect_context_compaction_output(
mut stream: ResponseStream,
) -> CodexResult<ResponseItem> {
let mut output_item_count = 0usize;
let mut context_compaction_count = 0usize;
let mut context_compaction_output = None;
let mut completed = false;
while let Some(event) = stream.next().await {
match event? {
ResponseEvent::OutputItemDone(item) => {
output_item_count += 1;
match item {
ResponseItem::ContextCompaction {
encrypted_content: Some(_),
} => {
context_compaction_count += 1;
if context_compaction_output.is_none() {
context_compaction_output = Some(item);
}
}
ResponseItem::ContextCompaction {
encrypted_content: None,
} => {
return Err(CodexErr::Fatal(
"remote compaction v2 returned context_compaction without encrypted_content"
.to_string(),
));
}
_ => {}
}
}
ResponseEvent::Completed { .. } => {
completed = true;
break;
}
_ => {}
}
}
if !completed {
return Err(CodexErr::Fatal(
"remote compaction v2 stream closed before response.completed".to_string(),
));
}
if context_compaction_count != 1 {
return Err(CodexErr::Fatal(format!(
"remote compaction v2 expected exactly one context_compaction output item, got {context_compaction_count} from {output_item_count} output items"
)));
}
let Some(context_compaction_output) = context_compaction_output else {
unreachable!("context compaction output must exist when count is exactly one");
};
Ok(context_compaction_output)
}
fn build_v2_compacted_history(
prompt_input: &[ResponseItem],
compaction_output: ResponseItem,
) -> Vec<ResponseItem> {
let mut retained = prompt_input
.iter()
.filter(|item| is_retained_for_remote_compaction_v2(item))
.cloned()
.collect::<Vec<_>>();
retained.push(compaction_output);
retained
}
fn is_retained_for_remote_compaction_v2(item: &ResponseItem) -> bool {
let ResponseItem::Message { role, .. } = item else {
return false;
};
matches!(role.as_str(), "user" | "developer" | "system")
}
#[cfg(test)]
mod tests {
use super::*;
use codex_protocol::models::ContentItem;
use codex_protocol::models::MessagePhase;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
fn message(role: &str, text: &str, phase: Option<MessagePhase>) -> ResponseItem {
ResponseItem::Message {
id: None,
role: role.to_string(),
content: vec![ContentItem::InputText {
text: text.to_string(),
}],
phase,
}
}
fn response_stream(events: Vec<CodexResult<ResponseEvent>>) -> ResponseStream {
let (tx_event, rx_event) = mpsc::channel(events.len().max(1));
for event in events {
tx_event
.try_send(event)
.expect("response stream test channel should have capacity");
}
drop(tx_event);
ResponseStream {
rx_event,
consumer_dropped: CancellationToken::new(),
}
}
#[test]
fn build_v2_compacted_history_matches_prod_retention_shape() {
let input = vec![
message("developer", "dev", /*phase*/ None),
message("system", "sys", /*phase*/ None),
message("user", "user", /*phase*/ None),
message("assistant", "commentary", Some(MessagePhase::Commentary)),
message("assistant", "final", Some(MessagePhase::FinalAnswer)),
ResponseItem::FunctionCall {
id: None,
name: "shell".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call_1".to_string(),
},
ResponseItem::Compaction {
encrypted_content: "old".to_string(),
},
];
let output = ResponseItem::ContextCompaction {
encrypted_content: Some("new".to_string()),
};
let history = build_v2_compacted_history(&input, output.clone());
assert_eq!(
history,
vec![
message("developer", "dev", /*phase*/ None),
message("system", "sys", /*phase*/ None),
message("user", "user", /*phase*/ None),
output,
]
);
}
#[tokio::test]
async fn collect_context_compaction_output_accepts_additional_output_items() {
let context_compaction = ResponseItem::ContextCompaction {
encrypted_content: Some("encrypted".to_string()),
};
let stream = response_stream(vec![
Ok(ResponseEvent::OutputItemDone(message(
"assistant",
"IGNORED_COMPACT_REPLY",
Some(MessagePhase::FinalAnswer),
))),
Ok(ResponseEvent::OutputItemDone(context_compaction.clone())),
Ok(ResponseEvent::Completed {
response_id: "resp-compact".to_string(),
token_usage: None,
end_turn: Some(true),
}),
]);
let output = collect_context_compaction_output(stream)
.await
.expect("context compaction should be collected");
assert_eq!(output, context_compaction);
}
}
+62
View File
@@ -8485,6 +8485,68 @@ hide_spawn_agent_metadata = true
Ok(())
}
#[tokio::test]
async fn multi_agent_v2_usage_hint_templates_use_materialized_config_values() -> std::io::Result<()>
{
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features.multi_agent_v2]
enabled = true
usage_hint_text = "Limit {{ features.multi_agent_v2.max_concurrent_threads_per_session }}"
root_agent_usage_hint_text = "Root {{ features.multi_agent_v2.max_concurrent_threads_per_session }}"
subagent_usage_hint_text = "Subagent {{ features.multi_agent_v2.max_concurrent_threads_per_session }}"
"#,
)?;
let config = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await?;
assert_eq!(
config.multi_agent_v2.usage_hint_text.as_deref(),
Some("Limit 4")
);
assert_eq!(
config.multi_agent_v2.root_agent_usage_hint_text.as_deref(),
Some("Root 4")
);
assert_eq!(
config.multi_agent_v2.subagent_usage_hint_text.as_deref(),
Some("Subagent 4")
);
Ok(())
}
#[tokio::test]
async fn multi_agent_v2_usage_hint_templates_fail_when_placeholder_is_missing() {
let codex_home = TempDir::new().expect("create codex home");
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"[features.multi_agent_v2]
enabled = true
usage_hint_text = "{{ features.multi_agent_v2.does_not_exist }}"
"#,
)
.expect("write config");
let err = ConfigBuilder::without_managed_config_for_tests()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.build()
.await
.expect_err("config load should fail");
assert!(
err.to_string()
.contains("features.multi_agent_v2.does_not_exist"),
"unexpected error: {err}",
);
}
#[tokio::test]
async fn profile_multi_agent_v2_config_overrides_base() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
+58
View File
@@ -132,6 +132,7 @@ mod network_proxy_spec;
mod permissions;
#[cfg(test)]
mod schema;
pub(crate) mod template_interpolation;
pub use codex_config::Constrained;
pub use codex_config::ConstraintError;
pub use codex_config::ConstraintResult;
@@ -2015,6 +2016,63 @@ impl Config {
overrides: ConfigOverrides,
codex_home: AbsolutePathBuf,
config_layer_stack: ConfigLayerStack,
) -> std::io::Result<Self> {
let config = Self::build_config_with_layer_stack(
fs,
cfg.clone(),
overrides.clone(),
codex_home.clone(),
config_layer_stack.clone(),
)
.await?;
let mut interpolation_source_cfg = cfg.clone();
template_interpolation::apply_resolved_config_fields(
&config,
&mut interpolation_source_cfg,
)
.map_err(|err| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("failed to materialize config for interpolation: {err}"),
)
})?;
let interpolation_source =
toml::Value::try_from(interpolation_source_cfg).map_err(|err| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("failed to serialize config for interpolation: {err}"),
)
})?;
let mut interpolated_cfg = cfg;
let interpolated = template_interpolation::interpolate_config_string_fields(
&mut interpolated_cfg,
&interpolation_source,
)
.map_err(|err| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("failed to interpolate config template fields: {err}"),
)
})?;
if interpolated {
return Self::build_config_with_layer_stack(
fs,
interpolated_cfg,
overrides,
codex_home,
config_layer_stack,
)
.await;
}
Ok(config)
}
async fn build_config_with_layer_stack(
fs: &dyn ExecutorFileSystem,
cfg: ConfigToml,
overrides: ConfigOverrides,
codex_home: AbsolutePathBuf,
config_layer_stack: ConfigLayerStack,
) -> std::io::Result<Self> {
// Keep the large config-construction future off small test thread stacks.
Box::pin(async move {
@@ -0,0 +1,189 @@
use anyhow::Context;
use anyhow::bail;
use codex_config::config_toml::ConfigToml;
use codex_config::types::MemoriesToml;
use codex_features::AppsMcpPathOverrideConfigToml;
use codex_features::Feature;
use codex_features::FeatureToml;
use codex_features::FeaturesToml;
use codex_features::MultiAgentV2ConfigToml;
use codex_utils_template::Template;
use toml::Value as TomlValue;
use super::Config;
const INTERPOLATED_CONFIG_STRING_FIELDS: &[&str] = &[
"features.multi_agent_v2.usage_hint_text",
"features.multi_agent_v2.root_agent_usage_hint_text",
"features.multi_agent_v2.subagent_usage_hint_text",
];
pub(crate) fn materialized_config_toml(config: &Config) -> anyhow::Result<ConfigToml> {
let mut materialized: ConfigToml = config
.config_layer_stack
.effective_config()
.try_into()
.context("failed to deserialize effective config for config interpolation")?;
apply_resolved_config_fields(config, &mut materialized)?;
Ok(materialized)
}
pub(crate) fn interpolate_config_string_fields(
config_toml: &mut ConfigToml,
interpolation_source: &TomlValue,
) -> anyhow::Result<bool> {
let mut target_value = TomlValue::try_from(config_toml.clone())
.context("failed to serialize config for interpolation")?;
let mut changed = false;
for field_path in INTERPOLATED_CONFIG_STRING_FIELDS {
let Some(value) = value_mut_at_path(&mut target_value, field_path) else {
continue;
};
let Some(template_source) = value.as_str() else {
bail!("interpolated config field `{field_path}` must be a string");
};
let template = Template::parse(template_source)
.with_context(|| format!("failed to parse template in config field `{field_path}`"))?;
let rendered = render_template(&template, interpolation_source, field_path)?;
if rendered != template_source {
*value = TomlValue::String(rendered);
changed = true;
}
}
if changed {
*config_toml = target_value
.try_into()
.context("failed to deserialize interpolated config")?;
}
Ok(changed)
}
pub(crate) fn apply_resolved_config_fields(
config: &Config,
config_toml: &mut ConfigToml,
) -> anyhow::Result<()> {
config_toml.web_search = Some(config.web_search_mode.value());
config_toml.model_provider = Some(config.model_provider_id.clone());
config_toml.plan_mode_reasoning_effort = config.plan_mode_reasoning_effort;
config_toml.model_verbosity = config.model_verbosity;
config_toml.include_permissions_instructions = Some(config.include_permissions_instructions);
config_toml.include_apps_instructions = Some(config.include_apps_instructions);
config_toml.include_environment_context = Some(config.include_environment_context);
config_toml.background_terminal_max_timeout = Some(config.background_terminal_max_timeout);
// Feature aliases and feature configs need to be written in their resolved
// form; otherwise replay can drift when a legacy key maps to the same
// runtime feature.
let features = config_toml
.features
.get_or_insert_with(FeaturesToml::default);
features.materialize_resolved_enabled(config.features.get());
let mut multi_agent_v2: MultiAgentV2ConfigToml =
resolved_config_to_toml(&config.multi_agent_v2, "features.multi_agent_v2")?;
multi_agent_v2.enabled = Some(config.features.enabled(Feature::MultiAgentV2));
features.multi_agent_v2 = Some(FeatureToml::Config(multi_agent_v2));
features.apps_mcp_path_override = Some(FeatureToml::Config(AppsMcpPathOverrideConfigToml {
enabled: Some(config.features.enabled(Feature::AppsMcpPathOverride)),
path: config.apps_mcp_path_override.clone(),
}));
config_toml.memories = Some(resolved_config_to_toml::<MemoriesToml>(
&config.memories,
"memories",
)?);
let agents = config_toml.agents.get_or_insert_with(Default::default);
// Multi-agent v2 owns thread fanout through its feature config. Preserve
// the legacy agents.max_threads setting only when v2 is disabled.
agents.max_threads = if config.features.enabled(Feature::MultiAgentV2) {
None
} else {
config.agent_max_threads
};
agents.max_depth = Some(config.agent_max_depth);
agents.job_max_runtime_seconds = config.agent_job_max_runtime_seconds;
agents.interrupt_message = Some(config.agent_interrupt_message_enabled);
config_toml
.skills
.get_or_insert_with(Default::default)
.include_instructions = Some(config.include_skill_instructions);
Ok(())
}
fn render_template(
template: &Template,
interpolation_source: &TomlValue,
field_path: &str,
) -> anyhow::Result<String> {
let variables =
template
.placeholders()
.map(|placeholder| {
let value = lookup_scalar_path(interpolation_source, placeholder).with_context(|| {
format!("failed to render config field `{field_path}` placeholder `{placeholder}`")
})?;
Ok((placeholder.to_string(), value))
})
.collect::<anyhow::Result<Vec<_>>>()?;
template
.render(
variables
.iter()
.map(|(name, value)| (name.as_str(), value.as_str())),
)
.with_context(|| format!("failed to render config field `{field_path}`"))
}
fn lookup_scalar_path(value: &TomlValue, path: &str) -> anyhow::Result<String> {
let resolved = value_at_path(value, path)
.with_context(|| format!("template placeholder `{path}` does not exist"))?;
match resolved {
TomlValue::String(value) => Ok(value.clone()),
TomlValue::Integer(value) => Ok(value.to_string()),
TomlValue::Float(value) => Ok(value.to_string()),
TomlValue::Boolean(value) => Ok(value.to_string()),
_ => bail!(
"template placeholder `{path}` must resolve to a scalar string, integer, float, or boolean"
),
}
}
fn value_at_path<'a>(value: &'a TomlValue, path: &str) -> Option<&'a TomlValue> {
let mut current = value;
for segment in path.split('.') {
current = current.as_table()?.get(segment)?;
}
Some(current)
}
fn value_mut_at_path<'a>(value: &'a mut TomlValue, path: &str) -> Option<&'a mut TomlValue> {
let mut current = value;
let mut segments = path.split('.').peekable();
while let Some(segment) = segments.next() {
let table = current.as_table_mut()?;
if segments.peek().is_none() {
return table.get_mut(segment);
}
current = table.get_mut(segment)?;
}
Some(current)
}
fn resolved_config_to_toml<Toml>(
value: &impl serde::Serialize,
label: &'static str,
) -> anyhow::Result<Toml>
where
Toml: serde::de::DeserializeOwned + serde::Serialize,
{
crate::config_lock::toml_round_trip(value, label).map_err(anyhow::Error::from)
}
+8 -2
View File
@@ -400,6 +400,7 @@ impl ContextManager {
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::Compaction { .. }
| ResponseItem::ContextCompaction { .. }
| ResponseItem::Other => item.clone(),
}
}
@@ -487,7 +488,8 @@ fn is_api_message(message: &ResponseItem) -> bool {
| ResponseItem::Reasoning { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::Compaction { .. } => true,
| ResponseItem::Compaction { .. }
| ResponseItem::ContextCompaction { .. } => true,
ResponseItem::Other => false,
}
}
@@ -535,6 +537,9 @@ pub(crate) fn estimate_response_item_model_visible_bytes(item: &ResponseItem) ->
}
| ResponseItem::Compaction {
encrypted_content: content,
}
| ResponseItem::ContextCompaction {
encrypted_content: Some(content),
} => i64::try_from(estimate_reasoning_length(content.len())).unwrap_or(i64::MAX),
item => {
let raw = serde_json::to_string(item)
@@ -681,7 +686,8 @@ fn is_model_generated_item(item: &ResponseItem) -> bool {
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::Compaction { .. } => true,
| ResponseItem::Compaction { .. }
| ResponseItem::ContextCompaction { .. } => true,
ResponseItem::FunctionCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::CustomToolCallOutput { .. }
+22 -72
View File
@@ -270,10 +270,11 @@ impl Session {
/// starts capture the active goal and token baseline, tool completions
/// account usage and may inject budget steering, completion accounting
/// suppresses that steering, external mutations account best-effort before
/// changing state, interrupts pause active goals, resumes reactivate paused
/// goals, explicit maybe-continue events start idle goal continuation turns,
/// and continuation turns with no counted autonomous activity suppress the
/// next automatic continuation until user/tool/external activity resets it.
/// changing state, interrupts pause active goals, thread resumes restore
/// runtime state for already-active goals, explicit maybe-continue events
/// start idle goal continuation turns, and continuation turns with no counted
/// autonomous activity suppress the next automatic continuation until
/// user/tool/external activity resets it.
pub(crate) fn goal_runtime_apply<'a>(
self: &'a Arc<Self>,
event: GoalRuntimeEvent<'a>,
@@ -339,7 +340,7 @@ impl Session {
Ok(())
}),
GoalRuntimeEvent::ThreadResumed => Box::pin(async move {
self.activate_paused_thread_goal_after_resume().await?;
self.restore_thread_goal_runtime_after_resume().await?;
Ok(())
}),
}
@@ -1016,15 +1017,15 @@ impl Session {
Ok(())
}
async fn activate_paused_thread_goal_after_resume(&self) -> anyhow::Result<bool> {
async fn restore_thread_goal_runtime_after_resume(&self) -> anyhow::Result<()> {
if !self.enabled(Feature::Goals) {
return Ok(false);
return Ok(());
}
if should_ignore_goal_for_mode(self.collaboration_mode().await.mode) {
tracing::debug!(
"skipping paused goal auto-resume while current collaboration mode ignores goals"
"skipping goal runtime restore while current collaboration mode ignores goals"
);
return Ok(false);
return Ok(());
}
let _continuation_guard = self
@@ -1034,79 +1035,28 @@ impl Session {
.await
.context("goal continuation semaphore closed")?;
let Some(state_db) = self.state_db_for_thread_goals().await? else {
return Ok(false);
return Ok(());
};
let Some(goal) = state_db.get_thread_goal(self.conversation_id).await? else {
*self.goal_runtime.budget_limit_reported_goal_id.lock().await = None;
self.goal_runtime
.accounting
.lock()
.await
.wall_clock
.clear_active_goal();
return Ok(false);
self.clear_stopped_thread_goal_runtime_state().await;
return Ok(());
};
if goal.status != codex_state::ThreadGoalStatus::Paused {
let goal_id = goal.goal_id.clone();
let is_active = goal.status == codex_state::ThreadGoalStatus::Active;
if is_active {
match goal.status {
codex_state::ThreadGoalStatus::Active => {
self.goal_runtime
.accounting
.lock()
.await
.wall_clock
.mark_active_goal(goal_id);
} else {
self.goal_runtime
.accounting
.lock()
.await
.wall_clock
.clear_active_goal();
.mark_active_goal(goal.goal_id);
}
codex_state::ThreadGoalStatus::Paused
| codex_state::ThreadGoalStatus::BudgetLimited
| codex_state::ThreadGoalStatus::Complete => {
self.clear_stopped_thread_goal_runtime_state().await;
}
return Ok(false);
}
let Some(goal) = state_db
.update_thread_goal(
self.conversation_id,
codex_state::ThreadGoalUpdate {
status: Some(codex_state::ThreadGoalStatus::Active),
token_budget: None,
expected_goal_id: Some(goal.goal_id.clone()),
},
)
.await?
else {
*self.goal_runtime.budget_limit_reported_goal_id.lock().await = None;
self.goal_runtime
.accounting
.lock()
.await
.wall_clock
.clear_active_goal();
return Ok(false);
};
let goal_id = goal.goal_id.clone();
let goal = protocol_goal_from_state(goal);
*self.goal_runtime.budget_limit_reported_goal_id.lock().await = None;
let active_turn_id = self
.active_turn_context()
.await
.map(|turn_context| turn_context.sub_id.clone());
let current_token_usage = self.total_token_usage().await.unwrap_or_default();
self.mark_active_goal_accounting(goal_id, active_turn_id, current_token_usage)
.await;
self.send_event_raw(Event {
id: uuid::Uuid::new_v4().to_string(),
msg: EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent {
thread_id: self.conversation_id,
turn_id: None,
goal,
}),
})
.await;
Ok(true)
Ok(())
}
async fn maybe_continue_goal_if_idle_runtime(self: &Arc<Self>) {
+1
View File
@@ -17,6 +17,7 @@ pub(crate) mod session;
pub use session::SteerInputError;
mod codex_thread;
mod compact_remote;
mod compact_remote_v2;
mod config_lock;
pub use codex_thread::CodexThread;
pub use codex_thread::CodexThreadTurnContextOverrides;
+101 -31
View File
@@ -44,12 +44,13 @@ use codex_mcp::SandboxState;
use codex_mcp::declared_openai_file_input_param_names;
use codex_mcp::mcp_permission_prompt_is_auto_approved;
use codex_otel::sanitize_metric_tag_value;
use codex_protocol::items::McpToolCallError;
use codex_protocol::items::McpToolCallItem;
use codex_protocol::items::McpToolCallStatus;
use codex_protocol::items::TurnItem;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::openai_models::InputModality;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::McpInvocation;
use codex_protocol::protocol::McpToolCallBeginEvent;
use codex_protocol::protocol::McpToolCallEndEvent;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::request_user_input::RequestUserInputAnswer;
use codex_protocol::request_user_input::RequestUserInputArgs;
@@ -86,8 +87,8 @@ const MCP_RESULT_TELEMETRY_SERVER_USER_FLOW_SPAN_ATTR: &str =
const MCP_RESULT_TELEMETRY_TARGET_ID_MAX_CHARS: usize = 256;
const MCP_TOOL_CALL_EVENT_RESULT_MAX_BYTES: usize = DEFAULT_OUTPUT_BYTES_CAP;
/// Handles the specified tool call dispatches the appropriate
/// `McpToolCallBegin` and `McpToolCallEnd` events to the `Session`.
/// Handles the specified tool call and dispatches the appropriate MCP tool-call
/// item lifecycle events to the `Session`.
pub(crate) async fn handle_mcp_tool_call(
sess: Arc<Session>,
turn_context: &Arc<TurnContext>,
@@ -191,12 +192,14 @@ pub(crate) async fn handle_mcp_tool_call(
.as_ref()
.and_then(|metadata| metadata.connector_name.clone());
let tool_call_begin_event = EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
call_id: call_id.clone(),
invocation: invocation.clone(),
mcp_app_resource_uri: mcp_app_resource_uri.clone(),
});
notify_mcp_tool_call_event(sess.as_ref(), turn_context.as_ref(), tool_call_begin_event).await;
notify_mcp_tool_call_started(
sess.as_ref(),
turn_context.as_ref(),
&call_id,
invocation.clone(),
mcp_app_resource_uri.clone(),
)
.await;
if let Some(decision) = maybe_request_mcp_tool_approval(
&sess,
@@ -373,14 +376,16 @@ async fn handle_approved_mcp_tool_call(
tracing::warn!("MCP tool call error: {error:?}");
}
let duration = start.elapsed();
let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent {
call_id: call_id.to_string(),
notify_mcp_tool_call_completed(
sess,
turn_context,
call_id,
invocation,
mcp_app_resource_uri,
duration,
result: truncate_mcp_tool_result_for_event(&result),
});
notify_mcp_tool_call_event(sess, turn_context, tool_call_end_event.clone()).await;
truncate_mcp_tool_result_for_event(&result),
)
.await;
maybe_track_codex_app_used(
sess,
turn_context,
@@ -676,7 +681,7 @@ fn truncate_mcp_tool_result_for_event(
) -> Result<CallToolResult, String> {
match result {
Ok(call_tool_result) => {
// The app-server rebuilds `ThreadItem::McpToolCall` from this event,
// The app-server rebuilds `ThreadItem::McpToolCall` from this item,
// so avoid persisting multi-megabyte results in rollout storage.
let Ok(serialized) = serde_json::to_string(call_tool_result) else {
return Ok(call_tool_result.clone());
@@ -715,8 +720,69 @@ fn truncate_mcp_tool_result_for_event(
}
}
async fn notify_mcp_tool_call_event(sess: &Session, turn_context: &TurnContext, event: EventMsg) {
sess.send_event(turn_context, event).await;
async fn notify_mcp_tool_call_started(
sess: &Session,
turn_context: &TurnContext,
call_id: &str,
invocation: McpInvocation,
mcp_app_resource_uri: Option<String>,
) {
let McpInvocation {
server,
tool,
arguments,
} = invocation;
let item = TurnItem::McpToolCall(McpToolCallItem {
id: call_id.to_string(),
server,
tool,
arguments: arguments.unwrap_or(JsonValue::Null),
mcp_app_resource_uri,
status: McpToolCallStatus::InProgress,
result: None,
error: None,
duration: None,
});
sess.emit_turn_item_started(turn_context, &item).await;
}
async fn notify_mcp_tool_call_completed(
sess: &Session,
turn_context: &TurnContext,
call_id: &str,
invocation: McpInvocation,
mcp_app_resource_uri: Option<String>,
duration: Duration,
result: Result<CallToolResult, String>,
) {
let (status, result, error) = match result {
Ok(result) if result.is_error.unwrap_or(false) => {
(McpToolCallStatus::Failed, Some(result), None)
}
Ok(result) => (McpToolCallStatus::Completed, Some(result), None),
Err(message) => (
McpToolCallStatus::Failed,
None,
Some(McpToolCallError { message }),
),
};
let McpInvocation {
server,
tool,
arguments,
} = invocation;
let item = TurnItem::McpToolCall(McpToolCallItem {
id: call_id.to_string(),
server,
tool,
arguments: arguments.unwrap_or(JsonValue::Null),
mcp_app_resource_uri,
status,
result,
error,
duration: Some(duration),
});
sess.emit_turn_item_completed(turn_context, item).await;
}
struct McpAppUsageMetadata {
@@ -2038,22 +2104,26 @@ async fn notify_mcp_tool_call_skip(
already_started: bool,
) -> Result<CallToolResult, String> {
if !already_started {
let tool_call_begin_event = EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
call_id: call_id.to_string(),
invocation: invocation.clone(),
mcp_app_resource_uri: mcp_app_resource_uri.clone(),
});
notify_mcp_tool_call_event(sess, turn_context, tool_call_begin_event).await;
notify_mcp_tool_call_started(
sess,
turn_context,
call_id,
invocation.clone(),
mcp_app_resource_uri.clone(),
)
.await;
}
let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent {
call_id: call_id.to_string(),
notify_mcp_tool_call_completed(
sess,
turn_context,
call_id,
invocation,
mcp_app_resource_uri,
duration: Duration::ZERO,
result: truncate_mcp_tool_result_for_event(&Err(message.clone())),
});
notify_mcp_tool_call_event(sess, turn_context, tool_call_end_event).await;
Duration::ZERO,
truncate_mcp_tool_result_for_event(&Err(message.clone())),
)
.await;
Err(message)
}
+4 -85
View File
@@ -1,19 +1,12 @@
use anyhow::Context;
use codex_config::config_toml::ConfigLockfileToml;
use codex_config::config_toml::ConfigToml;
use codex_config::types::MemoriesToml;
use codex_features::AppsMcpPathOverrideConfigToml;
use codex_features::Feature;
use codex_features::FeatureToml;
use codex_features::FeaturesToml;
use codex_features::MultiAgentV2ConfigToml;
use codex_protocol::ThreadId;
use crate::config::Config;
use crate::config::template_interpolation::materialized_config_toml;
use crate::config_lock::ConfigLockReplayOptions;
use crate::config_lock::clear_config_lock_debug_controls;
use crate::config_lock::config_lockfile;
use crate::config_lock::toml_round_trip;
use crate::config_lock::validate_config_lock_replay;
use super::SessionConfiguration;
@@ -81,20 +74,12 @@ fn session_configuration_to_lock_config_toml(
sc: &SessionConfiguration,
) -> anyhow::Result<ConfigToml> {
let config = sc.original_config_do_not_use.as_ref();
// Start from the resolved layer stack, then patch in values that are only
// known after session setup. Export and replay validation both use this
// path, so every field here is part of the lockfile contract.
let mut lock_config: ConfigToml = config
.config_layer_stack
.effective_config()
.try_into()
.context("failed to deserialize effective config for config lock")?;
let mut lock_config = materialized_config_toml(config)?;
if config.config_lock_save_fields_resolved_from_model_catalog {
save_session_resolved_fields(sc, &mut lock_config);
}
save_config_resolved_fields(config, &mut lock_config)?;
drop_lockfile_inputs(&mut lock_config);
Ok(lock_config)
@@ -118,64 +103,6 @@ fn save_session_resolved_fields(sc: &SessionConfiguration, lock_config: &mut Con
lock_config.approvals_reviewer = Some(sc.approvals_reviewer);
}
/// Saves values stored on `Config` after higher-level resolution,
/// normalization, defaulting, or feature materialization.
///
/// Persist the resolved representation so replay compares against the behavior
/// Codex actually ran with, not only the user-authored TOML inputs.
fn save_config_resolved_fields(
config: &Config,
lock_config: &mut ConfigToml,
) -> anyhow::Result<()> {
lock_config.web_search = Some(config.web_search_mode.value());
lock_config.model_provider = Some(config.model_provider_id.clone());
lock_config.plan_mode_reasoning_effort = config.plan_mode_reasoning_effort;
lock_config.model_verbosity = config.model_verbosity;
lock_config.include_permissions_instructions = Some(config.include_permissions_instructions);
lock_config.include_apps_instructions = Some(config.include_apps_instructions);
lock_config.include_environment_context = Some(config.include_environment_context);
lock_config.background_terminal_max_timeout = Some(config.background_terminal_max_timeout);
// Feature aliases and feature configs need to be written in their resolved
// form; otherwise replay can drift when a legacy key maps to the same
// runtime feature.
let features = lock_config
.features
.get_or_insert_with(FeaturesToml::default);
features.materialize_resolved_enabled(config.features.get());
let mut multi_agent_v2: MultiAgentV2ConfigToml =
resolved_config_to_toml(&config.multi_agent_v2, "features.multi_agent_v2")?;
multi_agent_v2.enabled = Some(config.features.enabled(Feature::MultiAgentV2));
features.multi_agent_v2 = Some(FeatureToml::Config(multi_agent_v2));
features.apps_mcp_path_override = Some(FeatureToml::Config(AppsMcpPathOverrideConfigToml {
enabled: Some(config.features.enabled(Feature::AppsMcpPathOverride)),
path: config.apps_mcp_path_override.clone(),
}));
lock_config.memories = Some(resolved_config_to_toml::<MemoriesToml>(
&config.memories,
"memories",
)?);
let agents = lock_config.agents.get_or_insert_with(Default::default);
// Multi-agent v2 owns thread fanout through its feature config. Preserve
// the legacy agents.max_threads setting only when v2 is disabled.
agents.max_threads = if config.features.enabled(Feature::MultiAgentV2) {
None
} else {
config.agent_max_threads
};
agents.max_depth = Some(config.agent_max_depth);
agents.job_max_runtime_seconds = config.agent_job_max_runtime_seconds;
agents.interrupt_message = Some(config.agent_interrupt_message_enabled);
lock_config
.skills
.get_or_insert_with(Default::default)
.include_instructions = Some(config.include_skill_instructions);
Ok(())
}
fn drop_lockfile_inputs(lock_config: &mut ConfigToml) {
// The lockfile should contain replayable values, not the profile,
// debug-control, file-include, and environment-specific inputs that
@@ -195,19 +122,11 @@ fn drop_lockfile_inputs(lock_config: &mut ConfigToml) {
lock_config.experimental_use_freeform_apply_patch = None;
}
fn resolved_config_to_toml<Toml>(
value: &impl serde::Serialize,
label: &'static str,
) -> anyhow::Result<Toml>
where
Toml: serde::de::DeserializeOwned + serde::Serialize,
{
toml_round_trip(value, label).map_err(anyhow::Error::from)
}
#[cfg(test)]
mod tests {
use super::*;
use codex_features::FeatureToml;
use codex_features::MultiAgentV2ConfigToml;
use pretty_assertions::assert_eq;
use std::sync::Arc;
+4 -3
View File
@@ -875,9 +875,10 @@ impl Session {
let beta_features_header = FEATURES
.iter()
.filter_map(|spec| {
if spec.stage.experimental_menu_description().is_some()
&& config.features.enabled(spec.id)
{
let advertise_in_model_client_header =
spec.stage.experimental_menu_description().is_some()
|| spec.id == Feature::RemoteCompactionV2;
if advertise_in_model_client_header && config.features.enabled(spec.id) {
Some(spec.key)
} else {
None
+53 -23
View File
@@ -16,6 +16,7 @@ use crate::compact::collect_user_messages;
use crate::compact::run_inline_auto_compact_task;
use crate::compact::should_use_remote_compact_task;
use crate::compact_remote::run_inline_remote_auto_compact_task;
use crate::compact_remote_v2::run_inline_remote_auto_compact_task as run_inline_remote_auto_compact_task_v2;
use crate::connectors;
use crate::context::ContextualUserFragment;
use crate::feedback_tags;
@@ -97,7 +98,7 @@ use codex_protocol::protocol::TurnDiffEvent;
use codex_protocol::protocol::WarningEvent;
use codex_protocol::user_input::UserInput;
use codex_tools::ToolName;
use codex_tools::filter_tool_suggest_discoverable_tools_for_client;
use codex_tools::filter_request_plugin_install_discoverable_tools_for_client;
use codex_utils_stream_parser::AssistantTextChunk;
use codex_utils_stream_parser::AssistantTextStreamParser;
use codex_utils_stream_parser::ProposedPlanSegment;
@@ -147,19 +148,21 @@ pub(crate) async fn run_turn(
let model_info = turn_context.model_info.clone();
let auto_compact_limit = model_info.auto_compact_token_limit().unwrap_or(i64::MAX);
let mut prewarmed_client_session = prewarmed_client_session;
let mut client_session =
prewarmed_client_session.unwrap_or_else(|| sess.services.model_client.new_session());
// TODO(ccunningham): Pre-turn compaction runs before context updates and the
// new user message are recorded. Estimate pending incoming items (context
// diffs/full reinjection + user input) and trigger compaction preemptively
// when they would push the thread over the compaction threshold.
let pre_sampling_compacted = match run_pre_sampling_compact(&sess, &turn_context).await {
Ok(pre_sampling_compacted) => pre_sampling_compacted,
Err(_) => {
error!("Failed to run pre-sampling compact");
return None;
}
};
if pre_sampling_compacted && let Some(mut client_session) = prewarmed_client_session.take() {
let pre_sampling_compact =
match run_pre_sampling_compact(&sess, &turn_context, &mut client_session).await {
Ok(pre_sampling_compact) => pre_sampling_compact,
Err(_) => {
error!("Failed to run pre-sampling compact");
return None;
}
};
if pre_sampling_compact.reset_client_session {
client_session.reset_websocket_session();
}
@@ -365,8 +368,6 @@ pub(crate) async fn run_turn(
// `ModelClientSession` is turn-scoped and caches WebSocket + sticky routing state, so we reuse
// one instance across retries within this turn.
let mut client_session =
prewarmed_client_session.unwrap_or_else(|| sess.services.model_client.new_session());
// Pending input is drained into history before building the next model request.
// However, we defer that drain until after sampling in two cases:
// 1. At the start of a turn, so the fresh user prompt in `input` gets sampled first.
@@ -484,19 +485,22 @@ pub(crate) async fn run_turn(
// as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop.
if token_limit_reached && needs_follow_up {
if run_auto_compact(
let reset_client_session = match run_auto_compact(
&sess,
&turn_context,
&mut client_session,
InitialContextInjection::BeforeLastUserMessage,
CompactionReason::ContextLimit,
CompactionPhase::MidTurn,
)
.await
.is_err()
{
return None;
Ok(reset_client_session) => reset_client_session,
Err(_) => return None,
};
if reset_client_session {
client_session.reset_websocket_session();
}
client_session.reset_websocket_session();
can_drain_pending_input = !model_needs_follow_up;
continue;
}
@@ -707,17 +711,24 @@ async fn track_turn_resolved_config_analytics(
});
}
struct PreSamplingCompactResult {
reset_client_session: bool,
}
async fn run_pre_sampling_compact(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
) -> CodexResult<bool> {
client_session: &mut ModelClientSession,
) -> CodexResult<PreSamplingCompactResult> {
let total_usage_tokens_before_compaction = sess.get_total_token_usage().await;
let mut pre_sampling_compacted = maybe_run_previous_model_inline_compact(
sess,
turn_context,
client_session,
total_usage_tokens_before_compaction,
)
.await?;
let mut reset_client_session = pre_sampling_compacted;
let total_usage_tokens = sess.get_total_token_usage().await;
let auto_compact_limit = turn_context
.model_info
@@ -725,9 +736,10 @@ async fn run_pre_sampling_compact(
.unwrap_or(i64::MAX);
// Compact if the total usage tokens are greater than the auto compact limit
if total_usage_tokens >= auto_compact_limit {
run_auto_compact(
reset_client_session |= run_auto_compact(
sess,
turn_context,
client_session,
InitialContextInjection::DoNotInject,
CompactionReason::ContextLimit,
CompactionPhase::PreTurn,
@@ -735,7 +747,9 @@ async fn run_pre_sampling_compact(
.await?;
pre_sampling_compacted = true;
}
Ok(pre_sampling_compacted)
Ok(PreSamplingCompactResult {
reset_client_session: pre_sampling_compacted && reset_client_session,
})
}
/// Runs pre-sampling compaction against the previous model when switching to a smaller
@@ -747,6 +761,7 @@ async fn run_pre_sampling_compact(
async fn maybe_run_previous_model_inline_compact(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
client_session: &mut ModelClientSession,
total_usage_tokens: i64,
) -> CodexResult<bool> {
let Some(previous_turn_settings) = sess.previous_turn_settings().await else {
@@ -772,9 +787,10 @@ async fn maybe_run_previous_model_inline_compact(
&& previous_model_turn_context.model_info.slug != turn_context.model_info.slug
&& old_context_window > new_context_window;
if should_run {
run_auto_compact(
let _ = run_auto_compact(
sess,
&previous_model_turn_context,
client_session,
InitialContextInjection::DoNotInject,
CompactionReason::ModelDownshift,
CompactionPhase::PreTurn,
@@ -788,11 +804,24 @@ async fn maybe_run_previous_model_inline_compact(
async fn run_auto_compact(
sess: &Arc<Session>,
turn_context: &Arc<TurnContext>,
client_session: &mut ModelClientSession,
initial_context_injection: InitialContextInjection,
reason: CompactionReason,
phase: CompactionPhase,
) -> CodexResult<()> {
) -> CodexResult<bool> {
if should_use_remote_compact_task(turn_context.provider.info()) {
if turn_context.features.enabled(Feature::RemoteCompactionV2) {
run_inline_remote_auto_compact_task_v2(
Arc::clone(sess),
Arc::clone(turn_context),
client_session,
initial_context_injection,
reason,
phase,
)
.await?;
return Ok(false);
}
run_inline_remote_auto_compact_task(
Arc::clone(sess),
Arc::clone(turn_context),
@@ -811,7 +840,7 @@ async fn run_auto_compact(
)
.await?;
}
Ok(())
Ok(true)
}
pub(super) fn collect_explicit_app_ids_from_skill_items(
@@ -1170,7 +1199,7 @@ pub(crate) async fn built_tools(
)
.await
.map(|discoverable_tools| {
filter_tool_suggest_discoverable_tools_for_client(
filter_request_plugin_install_discoverable_tools_for_client(
discoverable_tools,
turn_context.app_server_client_name.as_deref(),
)
@@ -1958,6 +1987,7 @@ async fn try_run_sampling_request(
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::Compaction { .. }
| ResponseItem::ContextCompaction { .. }
| ResponseItem::Other => false,
};
+8 -1
View File
@@ -33,7 +33,14 @@ impl SessionTask for CompactTask {
/*inc*/ 1,
&[("type", "remote")],
);
crate::compact_remote::run_remote_compact_task(session.clone(), ctx).await
if ctx
.features
.enabled(codex_features::Feature::RemoteCompactionV2)
{
crate::compact_remote_v2::run_remote_compact_task(session.clone(), ctx).await
} else {
crate::compact_remote::run_remote_compact_task(session.clone(), ctx).await
}
} else {
session.services.session_telemetry.counter(
"codex.task.compact",
+3 -3
View File
@@ -1123,7 +1123,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_
}
#[tokio::test]
async fn resumed_thread_activates_paused_goal_and_continues_on_request() -> anyhow::Result<()> {
async fn resumed_thread_keeps_paused_goal_paused() -> anyhow::Result<()> {
let temp_dir = tempdir().expect("tempdir");
let mut config = test_config().await;
config.codex_home = temp_dir.path().join("codex-home").abs();
@@ -1188,7 +1188,7 @@ async fn resumed_thread_activates_paused_goal_and_continues_on_request() -> anyh
.get_thread_goal(resumed.thread_id)
.await?
.expect("goal should still exist after resume");
assert_eq!(codex_state::ThreadGoalStatus::Active, goal.status);
assert_eq!(codex_state::ThreadGoalStatus::Paused, goal.status);
assert!(
resumed
.thread
@@ -1209,7 +1209,7 @@ async fn resumed_thread_activates_paused_goal_and_continues_on_request() -> anyh
.active_turn
.lock()
.await
.is_some()
.is_none()
);
resumed.thread.shutdown_and_wait().await?;
@@ -3,6 +3,10 @@ use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use codex_protocol::items::McpToolCallError;
use codex_protocol::items::McpToolCallItem;
use codex_protocol::items::McpToolCallStatus;
use codex_protocol::items::TurnItem;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::models::function_call_output_content_items_to_text;
use rmcp::model::ListResourceTemplatesResult;
@@ -25,10 +29,7 @@ use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::McpInvocation;
use codex_protocol::protocol::McpToolCallBeginEvent;
use codex_protocol::protocol::McpToolCallEndEvent;
pub struct McpResourceHandler;
@@ -564,16 +565,23 @@ async fn emit_tool_call_begin(
call_id: &str,
invocation: McpInvocation,
) {
session
.send_event(
turn,
EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
call_id: call_id.to_string(),
invocation,
mcp_app_resource_uri: None,
}),
)
.await;
let McpInvocation {
server,
tool,
arguments,
} = invocation;
let item = TurnItem::McpToolCall(McpToolCallItem {
id: call_id.to_string(),
server,
tool,
arguments: arguments.unwrap_or(Value::Null),
mcp_app_resource_uri: None,
status: McpToolCallStatus::InProgress,
result: None,
error: None,
duration: None,
});
session.emit_turn_item_started(turn, &item).await;
}
async fn emit_tool_call_end(
@@ -584,18 +592,34 @@ async fn emit_tool_call_end(
duration: Duration,
result: Result<CallToolResult, String>,
) {
session
.send_event(
turn,
EventMsg::McpToolCallEnd(McpToolCallEndEvent {
call_id: call_id.to_string(),
invocation,
mcp_app_resource_uri: None,
duration,
result,
}),
)
.await;
let (status, result, error) = match result {
Ok(result) if result.is_error.unwrap_or(false) => {
(McpToolCallStatus::Failed, Some(result), None)
}
Ok(result) => (McpToolCallStatus::Completed, Some(result), None),
Err(message) => (
McpToolCallStatus::Failed,
None,
Some(McpToolCallError { message }),
),
};
let McpInvocation {
server,
tool,
arguments,
} = invocation;
let item = TurnItem::McpToolCall(McpToolCallItem {
id: call_id.to_string(),
server,
tool,
arguments: arguments.unwrap_or(Value::Null),
mcp_app_resource_uri: None,
status,
result,
error,
duration: Some(duration),
});
session.emit_turn_item_completed(turn, item).await;
}
fn normalize_optional_string(input: Option<String>) -> Option<String> {
+2 -2
View File
@@ -10,11 +10,11 @@ pub(crate) mod multi_agents_common;
pub(crate) mod multi_agents_v2;
mod plan;
mod request_permissions;
mod request_plugin_install;
mod request_user_input;
mod shell;
mod test_sync;
mod tool_search;
mod tool_suggest;
mod unavailable_tool;
pub(crate) mod unified_exec;
mod view_image;
@@ -43,12 +43,12 @@ pub use mcp::McpHandler;
pub use mcp_resource::McpResourceHandler;
pub use plan::PlanHandler;
pub use request_permissions::RequestPermissionsHandler;
pub use request_plugin_install::RequestPluginInstallHandler;
pub use request_user_input::RequestUserInputHandler;
pub use shell::ShellCommandHandler;
pub use shell::ShellHandler;
pub use test_sync::TestSyncHandler;
pub use tool_search::ToolSearchHandler;
pub use tool_suggest::ToolSuggestHandler;
pub use unavailable_tool::UnavailableToolHandler;
pub(crate) use unavailable_tool::unavailable_tool_message;
pub use unified_exec::UnifiedExecHandler;
@@ -8,15 +8,15 @@ use codex_rmcp_client::ElicitationResponse;
use codex_tools::DiscoverableTool;
use codex_tools::DiscoverableToolAction;
use codex_tools::DiscoverableToolType;
use codex_tools::TOOL_SUGGEST_PERSIST_ALWAYS_VALUE;
use codex_tools::TOOL_SUGGEST_PERSIST_KEY;
use codex_tools::TOOL_SUGGEST_TOOL_NAME;
use codex_tools::ToolSuggestArgs;
use codex_tools::ToolSuggestResult;
use codex_tools::all_suggested_connectors_picked_up;
use codex_tools::build_tool_suggestion_elicitation_request;
use codex_tools::filter_tool_suggest_discoverable_tools_for_client;
use codex_tools::verified_connector_suggestion_completed;
use codex_tools::REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE;
use codex_tools::REQUEST_PLUGIN_INSTALL_PERSIST_KEY;
use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME;
use codex_tools::RequestPluginInstallArgs;
use codex_tools::RequestPluginInstallResult;
use codex_tools::all_requested_connectors_picked_up;
use codex_tools::build_request_plugin_install_elicitation_request;
use codex_tools::filter_request_plugin_install_discoverable_tools_for_client;
use codex_tools::verified_connector_install_completed;
use rmcp::model::RequestId;
use serde_json::Value;
use tracing::warn;
@@ -32,9 +32,9 @@ use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
pub struct ToolSuggestHandler;
pub struct RequestPluginInstallHandler;
impl ToolHandler for ToolSuggestHandler {
impl ToolHandler for RequestPluginInstallHandler {
type Output = FunctionToolOutput;
fn kind(&self) -> ToolKind {
@@ -43,7 +43,7 @@ impl ToolHandler for ToolSuggestHandler {
#[expect(
clippy::await_holding_invalid_type,
reason = "tool suggestion discovery reads through the session-owned manager guard"
reason = "plugin install discovery reads through the session-owned manager guard"
)]
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
@@ -58,12 +58,12 @@ impl ToolHandler for ToolSuggestHandler {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::Fatal(format!(
"{TOOL_SUGGEST_TOOL_NAME} handler received unsupported payload"
"{REQUEST_PLUGIN_INSTALL_TOOL_NAME} handler received unsupported payload"
)));
}
};
let args: ToolSuggestArgs = parse_arguments(&arguments)?;
let args: RequestPluginInstallArgs = parse_arguments(&arguments)?;
let suggest_reason = args.suggest_reason.trim();
if suggest_reason.is_empty() {
return Err(FunctionCallError::RespondToModel(
@@ -72,14 +72,15 @@ impl ToolHandler for ToolSuggestHandler {
}
if args.action_type != DiscoverableToolAction::Install {
return Err(FunctionCallError::RespondToModel(
"tool suggestions currently support only action_type=\"install\"".to_string(),
"plugin install requests currently support only action_type=\"install\""
.to_string(),
));
}
if args.tool_type == DiscoverableToolType::Plugin
&& turn.app_server_client_name.as_deref() == Some("codex-tui")
{
return Err(FunctionCallError::RespondToModel(
"plugin tool suggestions are not available in codex-tui yet".to_string(),
"plugin install requests are not available in codex-tui yet".to_string(),
));
}
@@ -98,14 +99,14 @@ impl ToolHandler for ToolSuggestHandler {
)
.await
.map(|discoverable_tools| {
filter_tool_suggest_discoverable_tools_for_client(
filter_request_plugin_install_discoverable_tools_for_client(
discoverable_tools,
turn.app_server_client_name.as_deref(),
)
})
.map_err(|err| {
FunctionCallError::RespondToModel(format!(
"tool suggestions are unavailable right now: {err}"
"plugin install requests are unavailable right now: {err}"
))
})?;
@@ -114,12 +115,12 @@ impl ToolHandler for ToolSuggestHandler {
.find(|tool| tool.tool_type() == args.tool_type && tool.id() == args.tool_id)
.ok_or_else(|| {
FunctionCallError::RespondToModel(format!(
"tool_id must match one of the discoverable tools exposed by {TOOL_SUGGEST_TOOL_NAME}"
"tool_id must match one of the discoverable tools exposed by {REQUEST_PLUGIN_INSTALL_TOOL_NAME}"
))
})?;
let request_id = RequestId::String(format!("tool_suggestion_{call_id}").into());
let params = build_tool_suggestion_elicitation_request(
let request_id = RequestId::String(format!("request_plugin_install_{call_id}").into());
let params = build_request_plugin_install_elicitation_request(
CODEX_APPS_MCP_SERVER_NAME,
session.conversation_id.to_string(),
turn.sub_id.clone(),
@@ -131,14 +132,14 @@ impl ToolHandler for ToolSuggestHandler {
.request_mcp_server_elicitation(turn.as_ref(), request_id, params)
.await;
if let Some(response) = response.as_ref() {
maybe_persist_tool_suggest_disable(&session, &turn, &tool, response).await;
maybe_persist_disabled_install_request(&session, &turn, &tool, response).await;
}
let user_confirmed = response
.as_ref()
.is_some_and(|response| response.action == ElicitationAction::Accept);
let completed = if user_confirmed {
verify_tool_suggestion_completed(&session, &turn, &tool, auth.as_ref()).await
verify_request_plugin_install_completed(&session, &turn, &tool, auth.as_ref()).await
} else {
false
};
@@ -149,7 +150,7 @@ impl ToolHandler for ToolSuggestHandler {
.await;
}
let content = serde_json::to_string(&ToolSuggestResult {
let content = serde_json::to_string(&RequestPluginInstallResult {
completed,
user_confirmed,
tool_type: args.tool_type,
@@ -160,7 +161,7 @@ impl ToolHandler for ToolSuggestHandler {
})
.map_err(|err| {
FunctionCallError::Fatal(format!(
"failed to serialize {TOOL_SUGGEST_TOOL_NAME} response: {err}"
"failed to serialize {REQUEST_PLUGIN_INSTALL_TOOL_NAME} response: {err}"
))
})?;
@@ -168,17 +169,17 @@ impl ToolHandler for ToolSuggestHandler {
}
}
async fn maybe_persist_tool_suggest_disable(
async fn maybe_persist_disabled_install_request(
session: &crate::session::session::Session,
turn: &crate::session::turn_context::TurnContext,
tool: &DiscoverableTool,
response: &ElicitationResponse,
) {
if !tool_suggest_response_requests_persistent_disable(response) {
if !request_plugin_install_response_requests_persistent_disable(response) {
return;
}
if let Err(err) = persist_tool_suggest_disable(&turn.config.codex_home, tool).await {
if let Err(err) = persist_disabled_install_request(&turn.config.codex_home, tool).await {
warn!(
error = %err,
tool_id = tool.id(),
@@ -190,7 +191,9 @@ async fn maybe_persist_tool_suggest_disable(
session.reload_user_config_layer().await;
}
fn tool_suggest_response_requests_persistent_disable(response: &ElicitationResponse) -> bool {
fn request_plugin_install_response_requests_persistent_disable(
response: &ElicitationResponse,
) -> bool {
if response.action != ElicitationAction::Decline {
return false;
}
@@ -199,24 +202,24 @@ fn tool_suggest_response_requests_persistent_disable(response: &ElicitationRespo
.meta
.as_ref()
.and_then(Value::as_object)
.and_then(|meta| meta.get(TOOL_SUGGEST_PERSIST_KEY))
.and_then(|meta| meta.get(REQUEST_PLUGIN_INSTALL_PERSIST_KEY))
.and_then(Value::as_str)
== Some(TOOL_SUGGEST_PERSIST_ALWAYS_VALUE)
== Some(REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE)
}
async fn persist_tool_suggest_disable(
async fn persist_disabled_install_request(
codex_home: &codex_utils_absolute_path::AbsolutePathBuf,
tool: &DiscoverableTool,
) -> anyhow::Result<()> {
ConfigEditsBuilder::new(codex_home)
.with_edits([ConfigEdit::AddToolSuggestDisabledTool(
disabled_tool_suggestion(tool),
disabled_install_request(tool),
)])
.apply()
.await
}
fn disabled_tool_suggestion(tool: &DiscoverableTool) -> ToolSuggestDisabledTool {
fn disabled_install_request(tool: &DiscoverableTool) -> ToolSuggestDisabledTool {
match tool {
DiscoverableTool::Connector(connector) => {
ToolSuggestDisabledTool::connector(connector.id.as_str())
@@ -225,14 +228,14 @@ fn disabled_tool_suggestion(tool: &DiscoverableTool) -> ToolSuggestDisabledTool
}
}
async fn verify_tool_suggestion_completed(
async fn verify_request_plugin_install_completed(
session: &crate::session::session::Session,
turn: &crate::session::turn_context::TurnContext,
tool: &DiscoverableTool,
auth: Option<&codex_login::CodexAuth>,
) -> bool {
match tool {
DiscoverableTool::Connector(connector) => refresh_missing_suggested_connectors(
DiscoverableTool::Connector(connector) => refresh_missing_requested_connectors(
session,
turn,
auth,
@@ -241,17 +244,17 @@ async fn verify_tool_suggestion_completed(
)
.await
.is_some_and(|accessible_connectors| {
verified_connector_suggestion_completed(connector.id.as_str(), &accessible_connectors)
verified_connector_install_completed(connector.id.as_str(), &accessible_connectors)
}),
DiscoverableTool::Plugin(plugin) => {
session.reload_user_config_layer().await;
let config = session.get_config().await;
let completed = verified_plugin_suggestion_completed(
let completed = verified_plugin_install_completed(
plugin.id.as_str(),
config.as_ref(),
session.services.plugins_manager.as_ref(),
);
let _ = refresh_missing_suggested_connectors(
let _ = refresh_missing_requested_connectors(
session,
turn,
auth,
@@ -268,7 +271,7 @@ async fn verify_tool_suggestion_completed(
clippy::await_holding_invalid_type,
reason = "connector cache refresh reads through the session-owned manager guard"
)]
async fn refresh_missing_suggested_connectors(
async fn refresh_missing_requested_connectors(
session: &crate::session::session::Session,
turn: &crate::session::turn_context::TurnContext,
auth: Option<&codex_login::CodexAuth>,
@@ -285,7 +288,7 @@ async fn refresh_missing_suggested_connectors(
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
&turn.config,
);
if all_suggested_connectors_picked_up(expected_connector_ids, &accessible_connectors) {
if all_requested_connectors_picked_up(expected_connector_ids, &accessible_connectors) {
return Some(accessible_connectors);
}
@@ -304,14 +307,14 @@ async fn refresh_missing_suggested_connectors(
}
Err(err) => {
warn!(
"failed to refresh codex apps tools cache after tool suggestion for {tool_id}: {err:#}"
"failed to refresh codex apps tools cache after plugin install request for {tool_id}: {err:#}"
);
None
}
}
}
fn verified_plugin_suggestion_completed(
fn verified_plugin_install_completed(
tool_id: &str,
config: &crate::config::Config,
plugins_manager: &codex_core_plugins::PluginsManager,
@@ -327,5 +330,5 @@ fn verified_plugin_suggestion_completed(
}
#[cfg(test)]
#[path = "tool_suggest_tests.rs"]
#[path = "request_plugin_install_tests.rs"]
mod tests;
@@ -22,7 +22,7 @@ use serde_json::json;
use tempfile::tempdir;
#[tokio::test]
async fn verified_plugin_suggestion_completed_requires_installed_plugin() {
async fn verified_plugin_install_completed_requires_installed_plugin() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["sample"]);
@@ -32,7 +32,7 @@ async fn verified_plugin_suggestion_completed_requires_installed_plugin() {
let config = load_plugins_config(codex_home.path()).await;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
assert!(!verified_plugin_suggestion_completed(
assert!(!verified_plugin_install_completed(
"sample@openai-curated",
&config,
&plugins_manager,
@@ -50,7 +50,7 @@ async fn verified_plugin_suggestion_completed_requires_installed_plugin() {
.expect("plugin should install");
let refreshed_config = load_plugins_config(codex_home.path()).await;
assert!(verified_plugin_suggestion_completed(
assert!(verified_plugin_install_completed(
"sample@openai-curated",
&refreshed_config,
&plugins_manager,
@@ -58,43 +58,47 @@ async fn verified_plugin_suggestion_completed_requires_installed_plugin() {
}
#[test]
fn tool_suggest_response_persists_only_decline_always_mode() {
assert!(tool_suggest_response_requests_persistent_disable(
fn request_plugin_install_response_persists_only_decline_always_mode() {
assert!(request_plugin_install_response_requests_persistent_disable(
&ElicitationResponse {
action: ElicitationAction::Decline,
content: None,
meta: Some(json!({ TOOL_SUGGEST_PERSIST_KEY: TOOL_SUGGEST_PERSIST_ALWAYS_VALUE })),
meta: Some(json!({
REQUEST_PLUGIN_INSTALL_PERSIST_KEY: REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE
})),
}
));
assert!(!tool_suggest_response_requests_persistent_disable(
&ElicitationResponse {
assert!(
!request_plugin_install_response_requests_persistent_disable(&ElicitationResponse {
action: ElicitationAction::Accept,
content: None,
meta: Some(json!({ TOOL_SUGGEST_PERSIST_KEY: TOOL_SUGGEST_PERSIST_ALWAYS_VALUE })),
}
));
assert!(!tool_suggest_response_requests_persistent_disable(
&ElicitationResponse {
meta: Some(json!({
REQUEST_PLUGIN_INSTALL_PERSIST_KEY: REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE
})),
})
);
assert!(
!request_plugin_install_response_requests_persistent_disable(&ElicitationResponse {
action: ElicitationAction::Decline,
content: None,
meta: Some(json!({ TOOL_SUGGEST_PERSIST_KEY: "session" })),
}
));
assert!(!tool_suggest_response_requests_persistent_disable(
&ElicitationResponse {
meta: Some(json!({ REQUEST_PLUGIN_INSTALL_PERSIST_KEY: "session" })),
})
);
assert!(
!request_plugin_install_response_requests_persistent_disable(&ElicitationResponse {
action: ElicitationAction::Decline,
content: None,
meta: None,
}
));
})
);
}
#[tokio::test]
async fn persist_tool_suggest_disable_writes_connector_config() {
async fn persist_disabled_install_request_writes_connector_config() {
let codex_home = tempdir().expect("tempdir should succeed");
let tool = connector_tool("connector_calendar", "Google Calendar");
persist_tool_suggest_disable(&codex_home.path().abs(), &tool)
persist_disabled_install_request(&codex_home.path().abs(), &tool)
.await
.expect("persist connector disable");
@@ -111,7 +115,7 @@ async fn persist_tool_suggest_disable_writes_connector_config() {
}
#[tokio::test]
async fn persist_tool_suggest_disable_writes_plugin_config() {
async fn persist_disabled_install_request_writes_plugin_config() {
let codex_home = tempdir().expect("tempdir should succeed");
let tool = DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo {
id: "slack@openai-curated".to_string(),
@@ -122,7 +126,7 @@ async fn persist_tool_suggest_disable_writes_plugin_config() {
app_connector_ids: Vec::new(),
}));
persist_tool_suggest_disable(&codex_home.path().abs(), &tool)
persist_disabled_install_request(&codex_home.path().abs(), &tool)
.await
.expect("persist plugin disable");
@@ -139,7 +143,7 @@ async fn persist_tool_suggest_disable_writes_plugin_config() {
}
#[tokio::test]
async fn persist_tool_suggest_disable_dedupes_existing_disabled_tools() {
async fn persist_disabled_install_request_dedupes_existing_disabled_tools() {
let codex_home = tempdir().expect("tempdir should succeed");
let tool = connector_tool("connector_calendar", "Google Calendar");
std::fs::write(
@@ -169,7 +173,7 @@ id = "slack@openai-curated"
)
.expect("write config");
persist_tool_suggest_disable(&codex_home.path().abs(), &tool)
persist_disabled_install_request(&codex_home.path().abs(), &tool)
.await
.expect("persist connector disable");
+4 -4
View File
@@ -86,12 +86,12 @@ pub(crate) fn build_specs_with_discoverable_tools(
use crate::tools::handlers::McpResourceHandler;
use crate::tools::handlers::PlanHandler;
use crate::tools::handlers::RequestPermissionsHandler;
use crate::tools::handlers::RequestPluginInstallHandler;
use crate::tools::handlers::RequestUserInputHandler;
use crate::tools::handlers::ShellCommandHandler;
use crate::tools::handlers::ShellHandler;
use crate::tools::handlers::TestSyncHandler;
use crate::tools::handlers::ToolSearchHandler;
use crate::tools::handlers::ToolSuggestHandler;
use crate::tools::handlers::UnavailableToolHandler;
use crate::tools::handlers::UnifiedExecHandler;
use crate::tools::handlers::ViewImageHandler;
@@ -174,7 +174,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
.cloned()
.collect::<Vec<_>>();
let mut tool_search_handler = None;
let tool_suggest_handler = Arc::new(ToolSuggestHandler);
let request_plugin_install_handler = Arc::new(RequestPluginInstallHandler);
let code_mode_handler = Arc::new(CodeModeExecuteHandler);
let code_mode_wait_handler = Arc::new(CodeModeWaitHandler);
let unavailable_tool_handler = Arc::new(UnavailableToolHandler);
@@ -281,8 +281,8 @@ pub(crate) fn build_specs_with_discoverable_tools(
builder.register_handler(handler.name, tool_search_handler.clone());
}
}
ToolHandlerKind::ToolSuggest => {
builder.register_handler(handler.name, tool_suggest_handler.clone());
ToolHandlerKind::RequestPluginInstall => {
builder.register_handler(handler.name, request_plugin_install_handler.clone());
}
ToolHandlerKind::UnifiedExec => {
builder.register_handler(handler.name, unified_exec_handler.clone());
+3 -3
View File
@@ -21,11 +21,11 @@ use codex_tools::ConfiguredToolSpec;
use codex_tools::DiscoverableTool;
use codex_tools::JsonSchema;
use codex_tools::LoadableToolSpec;
use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::ResponsesApiTool;
use codex_tools::ShellCommandBackendConfig;
use codex_tools::TOOL_SEARCH_TOOL_NAME;
use codex_tools::TOOL_SUGGEST_TOOL_NAME;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use codex_tools::ToolsConfig;
@@ -793,7 +793,7 @@ async fn multi_agent_v2_wait_agent_schema_uses_configured_min_timeout() {
}
#[tokio::test]
async fn tool_suggest_requires_apps_and_plugins_features() {
async fn request_plugin_install_requires_apps_and_plugins_features() {
let model_info = search_capable_model_info().await;
let discoverable_tools = Some(vec![discoverable_connector(
"connector_2128aebfecb84f64a069897515042a44",
@@ -833,7 +833,7 @@ async fn tool_suggest_requires_apps_and_plugins_features() {
assert!(
!tools
.iter()
.any(|tool| tool.name() == TOOL_SUGGEST_TOOL_NAME),
.any(|tool| tool.name() == REQUEST_PLUGIN_INSTALL_TOOL_NAME),
"tool_suggest should be absent when {disabled_feature:?} is disabled"
);
}
+2 -1
View File
@@ -186,7 +186,8 @@ fn response_item_records_turn_ttft(item: &ResponseItem) -> bool {
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::Compaction { .. } => true,
| ResponseItem::Compaction { .. }
| ResponseItem::ContextCompaction { .. } => true,
ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
@@ -0,0 +1,29 @@
# Request plugin/connector install
Use this tool only to ask the user to install one known plugin or connector from the list below. The list contains known candidates that are not currently installed.
Use this ONLY when all of the following are true:
- The user explicitly asks to use a specific plugin or connector that is not already available in the current context or active `tools` list.
- `tool_search` is not available, or it has already been called and did not find or make the requested tool callable.
- The plugin or connector is one of the known installable plugins or connectors listed below. Only ask to install plugins or connectors from this list.
Do not use this tool for adjacent capabilities, broad recommendations, or tools that merely seem useful. Only use when the user explicitly asks to use that exact listed plugin or connector.
Known plugins/connectors available to install:
{{discoverable_tools}}
Workflow:
1. Check the current context and active `tools` list first. If current active tools aren't relevant and `tool_search` is available, only call this tool after `tool_search` has already been tried and found no relevant tool.
2. Match the user's explicit request against the known plugin/connector list above. Only proceed when one listed plugin or connector exactly fits.
3. If we found both connectors and plugins to install, use plugins first, only use connectors if the corresponding plugin is installed but the connector is not.
4. If one plugin or connector clearly fits, call `request_plugin_install` with:
- `tool_type`: `connector` or `plugin`
- `action_type`: `install`
- `tool_id`: exact id from the known plugin/connector list above
- `suggest_reason`: concise one-line user-facing reason this plugin or connector can help with the current request
5. After the request flow completes:
- if the user finished the install flow, continue by searching again or using the newly available plugin or connector
- if the user did not finish, continue without that plugin or connector, and don't request it again unless the user explicitly asks for it.
IMPORTANT: DO NOT call this tool in parallel with other tools.
@@ -1,29 +0,0 @@
# Tool suggestion discovery
Use this tool only to ask the user to install one known plugin or connector from the list below. The list contains known candidates that are not currently installed.
Use this ONLY when all of the following are true:
- The user explicitly wants a specific plugin or connector that is not already available in the current context or active `tools` list.
- `tool_search` is not available, or it has already been called and did not find or make the requested tool callable.
- The tool is one of the known installable plugins or connectors listed below. Only ask to install tools from this list.
Do not use tool suggestion for adjacent capabilities, broad recommendations, or tools that merely seem useful. The user's intent must clearly match one listed tool.
Known plugins/connectors available to install:
{{discoverable_tools}}
Workflow:
1. Check the current context and active `tools` list first. If `tool_search` is available, call `tool_search` before calling `tool_suggest`. Do not use tool suggestion if the needed tool is already available, found through `tool_search`, or callable after discovery.
2. Match the user's explicit request against the known plugin/connector list above. Only proceed when one listed plugin or connector exactly fits.
3. If we found both connectors and plugins to suggest, use plugins first, only use connectors if the corresponding plugin is installed but the connector is not.
4. If one tool clearly fits, call `tool_suggest` with:
- `tool_type`: `connector` or `plugin`
- `action_type`: `install`
- `tool_id`: exact id from the known plugin/connector list above
- `suggest_reason`: concise one-line user-facing reason this tool can help with the current request
5. After the suggestion flow completes:
- if the user finished the install flow, continue by searching again or using the newly available tool
- if the user did not finish, continue without that tool, and don't suggest that tool again unless the user explicitly asks for it.
IMPORTANT: DO NOT call this tool in parallel with other tools.
+6 -1
View File
@@ -770,10 +770,15 @@ async fn includes_conversation_id_and_model_headers_in_request() {
let installation_id =
std::fs::read_to_string(test.codex_home_path().join(INSTALLATION_ID_FILENAME))
.expect("read installation id");
let session_id_string = session_id.to_string();
assert_eq!(request_session_id, session_id.to_string());
assert_eq!(request_session_id, session_id_string);
assert_eq!(request_originator, originator().value);
assert_eq!(request_authorization, "Bearer Test API Key");
assert_eq!(
request_body["prompt_cache_key"].as_str(),
Some(session_id_string.as_str())
);
assert_eq!(
request_body["client_metadata"]["x-codex-installation-id"].as_str(),
Some(installation_id.as_str())
+198
View File
@@ -5,6 +5,7 @@ use std::path::PathBuf;
use anyhow::Result;
use codex_core::compact::SUMMARY_PREFIX;
use codex_features::Feature;
use codex_login::CodexAuth;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::items::TurnItem;
@@ -411,6 +412,203 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_compact_v2_reuses_context_compaction_for_followups() -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = TestCodexHarness::with_builder(
test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(|config| {
let _ = config.features.enable(Feature::RemoteCompactionV2);
}),
)
.await?;
let codex = harness.test().codex.clone();
let responses_mock = responses::mount_sse_sequence(
harness.server(),
vec![
responses::sse(vec![
responses::ev_assistant_message("m1", "FIRST_REMOTE_REPLY"),
responses::ev_completed("resp-1"),
]),
responses::sse(vec![
serde_json::json!({
"type": "response.output_item.done",
"item": {
"type": "context_compaction",
"encrypted_content": "ENCRYPTED_CONTEXT_COMPACTION_SUMMARY",
}
}),
responses::ev_completed("resp-compact"),
]),
responses::sse(vec![
responses::ev_assistant_message("m2", "AFTER_COMPACT_REPLY"),
responses::ev_completed("resp-2"),
]),
],
)
.await;
codex
.submit(Op::UserInput {
environments: None,
items: vec![UserInput::Text {
text: "hello remote compact".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
})
.await?;
wait_for_turn_complete(&codex).await;
codex.submit(Op::Compact).await?;
wait_for_turn_complete(&codex).await;
codex
.submit(Op::UserInput {
environments: None,
items: vec![UserInput::Text {
text: "after compact".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
})
.await?;
wait_for_turn_complete(&codex).await;
let response_requests = responses_mock.requests();
let compact_request = &response_requests[1];
assert!(
compact_request
.header("x-codex-beta-features")
.as_deref()
.is_some_and(|value| value
.split(',')
.any(|feature| feature == "remote_compaction_v2")),
"expected compact request to advertise the remote_compaction_v2 beta feature"
);
assert_eq!(compact_request.path(), "/v1/responses");
let compact_body = compact_request.body_json().to_string();
assert!(
compact_body.contains("\"type\":\"context_compaction\""),
"expected v2 compaction request to include the context_compaction trigger item"
);
assert!(
!compact_body.contains("ENCRYPTED_CONTEXT_COMPACTION_SUMMARY"),
"expected v2 compaction trigger item to omit encrypted_content"
);
let follow_up_request = response_requests.last().expect("follow-up request missing");
let follow_up_body = follow_up_request.body_json().to_string();
assert!(
follow_up_body.contains("\"type\":\"context_compaction\""),
"expected follow-up request to preserve the v2 context_compaction item"
);
assert!(
follow_up_body.contains("ENCRYPTED_CONTEXT_COMPACTION_SUMMARY"),
"expected follow-up request to include the context compaction payload"
);
assert!(
follow_up_body.contains("hello remote compact"),
"expected v2 follow-up request to preserve retained original user messages"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_compact_v2_accepts_additional_output_items_before_context_compaction() -> Result<()>
{
skip_if_no_network!(Ok(()));
let harness = TestCodexHarness::with_builder(
test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(|config| {
let _ = config.features.enable(Feature::RemoteCompactionV2);
}),
)
.await?;
let codex = harness.test().codex.clone();
let responses_mock = responses::mount_sse_sequence(
harness.server(),
vec![
responses::sse(vec![
responses::ev_assistant_message("m1", "FIRST_REMOTE_REPLY"),
responses::ev_completed("resp-1"),
]),
responses::sse(vec![
responses::ev_assistant_message("m-compact-noise", "IGNORED_COMPACT_REPLY"),
serde_json::json!({
"type": "response.output_item.done",
"item": {
"type": "context_compaction",
"encrypted_content": "ENCRYPTED_CONTEXT_COMPACTION_SUMMARY",
}
}),
responses::ev_completed("resp-compact"),
]),
responses::sse(vec![
responses::ev_assistant_message("m2", "AFTER_COMPACT_REPLY"),
responses::ev_completed("resp-2"),
]),
],
)
.await;
codex
.submit(Op::UserInput {
environments: None,
items: vec![UserInput::Text {
text: "hello remote compact".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
})
.await?;
wait_for_turn_complete(&codex).await;
codex.submit(Op::Compact).await?;
wait_for_turn_complete(&codex).await;
codex
.submit(Op::UserInput {
environments: None,
items: vec![UserInput::Text {
text: "after compact".into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
})
.await?;
wait_for_turn_complete(&codex).await;
let response_requests = responses_mock.requests();
let follow_up_request = response_requests.last().expect("follow-up request missing");
let follow_up_body = follow_up_request.body_json().to_string();
assert!(
follow_up_body.contains("\"type\":\"context_compaction\""),
"expected follow-up request to preserve the v2 context_compaction item"
);
assert!(
follow_up_body.contains("ENCRYPTED_CONTEXT_COMPACTION_SUMMARY"),
"expected follow-up request to include the context compaction payload"
);
assert!(
!follow_up_body.contains("IGNORED_COMPACT_REPLY"),
"expected follow-up request to ignore unrelated output items from the compaction stream"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_compact_filters_deferred_dynamic_tools() -> Result<()> {
skip_if_no_network!(Ok(()));
+1 -1
View File
@@ -77,6 +77,7 @@ mod request_compression;
mod request_permissions;
#[cfg(not(target_os = "windows"))]
mod request_permissions_tool;
mod request_plugin_install;
mod request_user_input;
mod responses_api_proxy_headers;
mod resume;
@@ -98,7 +99,6 @@ mod stream_no_completed;
mod subagent_notifications;
mod tool_harness;
mod tool_parallelism;
mod tool_suggest;
mod tools;
mod truncation;
mod turn_state;
@@ -22,7 +22,7 @@ use core_test_support::test_codex::test_codex;
use serde_json::Value;
const TOOL_SEARCH_TOOL_NAME: &str = "tool_search";
const TOOL_SUGGEST_TOOL_NAME: &str = "tool_suggest";
const REQUEST_PLUGIN_INSTALL_TOOL_NAME: &str = "request_plugin_install";
const DISCOVERABLE_GMAIL_ID: &str = "connector_68df038e0ba48191908c8434991bbac2";
fn tool_names(body: &Value) -> Vec<String> {
@@ -89,7 +89,8 @@ fn configure_apps_without_search_tool(config: &mut Config, apps_base_url: &str)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tool_suggest_is_available_without_search_tool_after_discovery_attempts() -> Result<()> {
async fn request_plugin_install_is_available_without_search_tool_after_discovery_attempts()
-> Result<()> {
skip_if_no_network!(Ok(()));
let server = start_mock_server().await;
@@ -125,18 +126,23 @@ async fn tool_suggest_is_available_without_search_tool_after_discovery_attempts(
"tools list should not include {TOOL_SEARCH_TOOL_NAME}: {tools:?}"
);
assert!(
tools.iter().any(|name| name == TOOL_SUGGEST_TOOL_NAME),
"tools list should include {TOOL_SUGGEST_TOOL_NAME}: {tools:?}"
tools
.iter()
.any(|name| name == REQUEST_PLUGIN_INSTALL_TOOL_NAME),
"tools list should include {REQUEST_PLUGIN_INSTALL_TOOL_NAME}: {tools:?}"
);
let description =
function_tool_description(&body, TOOL_SUGGEST_TOOL_NAME).expect("description");
function_tool_description(&body, REQUEST_PLUGIN_INSTALL_TOOL_NAME).expect("description");
assert!(description.contains(
"Use this tool only to ask the user to install one known plugin or connector from the list below"
));
assert!(description.contains(
"`tool_search` is not available, or it has already been called and did not find or make the requested tool callable."
));
assert!(description.contains(
"Only use when the user explicitly asks to use that exact listed plugin or connector."
));
assert!(description.contains("IMPORTANT: DO NOT call this tool in parallel with other tools."));
assert!(!description.contains("tool_search fails to find a good match"));
+9 -1
View File
@@ -227,6 +227,8 @@ pub enum Feature {
ResponsesWebsockets,
/// Legacy rollout flag for Responses API WebSocket transport v2 experiments.
ResponsesWebsocketsV2,
/// Enable remote compaction v2 over the normal Responses API.
RemoteCompactionV2,
/// Enable workspace dependency support.
WorkspaceDependencies,
}
@@ -785,7 +787,7 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Experimental {
name: "Memories",
menu_description: "Allow Codex to create new memories from conversations and bring relevant memories into new conversations.",
announcement: "NEW: Codex can now generate and uses memories. Try is now with `/memories`",
announcement: "NEW: Codex can now generate and use memories. Try it now with `/memories`",
},
default_enabled: false,
},
@@ -1121,6 +1123,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Removed,
default_enabled: false,
},
FeatureSpec {
id: Feature::RemoteCompactionV2,
key: "remote_compaction_v2",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::WorkspaceDependencies,
key: "workspace_dependencies",
+10
View File
@@ -119,6 +119,16 @@ fn request_permissions_tool_is_under_development() {
assert_eq!(Feature::RequestPermissionsTool.default_enabled(), false);
}
#[test]
fn remote_compaction_v2_is_under_development() {
assert_eq!(Feature::RemoteCompactionV2.stage(), Stage::UnderDevelopment);
assert_eq!(Feature::RemoteCompactionV2.default_enabled(), false);
assert_eq!(
feature_for_key("remote_compaction_v2"),
Some(Feature::RemoteCompactionV2)
);
}
#[test]
fn terminal_resize_reflow_is_experimental_and_enabled_by_default() {
assert_eq!(
+6
View File
@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "mcp",
crate_name = "codex_memories_mcp",
)
+31
View File
@@ -0,0 +1,31 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-memories-mcp"
version.workspace = true
[lib]
name = "codex_memories_mcp"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
anyhow = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-output-truncation = { workspace = true }
rmcp = { workspace = true, default-features = false, features = [
"schemars",
"server",
] }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["fs", "io-std"] }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["fs", "macros"] }
+157
View File
@@ -0,0 +1,157 @@
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use std::future::Future;
pub const DEFAULT_LIST_MAX_RESULTS: usize = 2_000;
pub const MAX_LIST_RESULTS: usize = 2_000;
pub const DEFAULT_SEARCH_MAX_RESULTS: usize = 200;
pub const MAX_SEARCH_RESULTS: usize = 200;
pub const DEFAULT_READ_MAX_TOKENS: usize = 20_000;
/// Storage interface behind the memories MCP tools.
///
/// Implementations should return paths relative to the memory store and enforce
/// their own storage-specific access rules. The local implementation uses the
/// filesystem today; a later implementation can satisfy the same contract from a
/// remote backend.
pub trait MemoriesBackend: Clone + Send + Sync + 'static {
fn list(
&self,
request: ListMemoriesRequest,
) -> impl Future<Output = Result<ListMemoriesResponse, MemoriesBackendError>> + Send;
fn read(
&self,
request: ReadMemoryRequest,
) -> impl Future<Output = Result<ReadMemoryResponse, MemoriesBackendError>> + Send;
fn search(
&self,
request: SearchMemoriesRequest,
) -> impl Future<Output = Result<SearchMemoriesResponse, MemoriesBackendError>> + Send;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListMemoriesRequest {
pub path: Option<String>,
pub cursor: Option<String>,
pub max_results: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ListMemoriesResponse {
pub path: Option<String>,
pub entries: Vec<MemoryEntry>,
pub next_cursor: Option<String>,
pub truncated: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadMemoryRequest {
pub path: String,
pub line_offset: usize,
pub max_lines: Option<usize>,
pub max_tokens: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ReadMemoryResponse {
pub path: String,
pub start_line_number: usize,
pub content: String,
pub truncated: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchMemoriesRequest {
pub queries: Vec<String>,
pub match_mode: SearchMatchMode,
pub path: Option<String>,
pub cursor: Option<String>,
pub context_lines: usize,
pub case_sensitive: bool,
pub max_results: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct SearchMemoriesResponse {
pub queries: Vec<String>,
pub match_mode: SearchMatchMode,
pub path: Option<String>,
pub matches: Vec<MemorySearchMatch>,
pub next_cursor: Option<String>,
pub truncated: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum SearchMatchMode {
Any,
All,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct MemoryEntry {
pub path: String,
pub entry_type: MemoryEntryType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum MemoryEntryType {
File,
Directory,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct MemorySearchMatch {
pub path: String,
pub match_line_number: usize,
pub content_start_line_number: usize,
pub content: String,
pub matched_queries: Vec<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum MemoriesBackendError {
#[error("path '{path}' {reason}")]
InvalidPath { path: String, reason: String },
#[error("cursor '{cursor}' {reason}")]
InvalidCursor { cursor: String, reason: String },
#[error("path '{path}' was not found")]
NotFound { path: String },
#[error("line_offset must be a 1-indexed line number")]
InvalidLineOffset,
#[error("max_lines must be a positive integer")]
InvalidMaxLines,
#[error("line_offset exceeds file length")]
LineOffsetExceedsFileLength,
#[error("path '{path}' is not a file")]
NotFile { path: String },
#[error("queries must not be empty or contain empty strings")]
EmptyQuery,
#[error("I/O error while reading memories: {0}")]
Io(#[from] std::io::Error),
}
impl MemoriesBackendError {
pub fn invalid_path(path: impl Into<String>, reason: impl Into<String>) -> Self {
Self::InvalidPath {
path: path.into(),
reason: reason.into(),
}
}
pub fn invalid_cursor(cursor: impl Into<String>, reason: impl Into<String>) -> Self {
Self::InvalidCursor {
cursor: cursor.into(),
reason: reason.into(),
}
}
}
+14
View File
@@ -0,0 +1,14 @@
//! MCP access to Codex memories.
//!
//! This crate only exposes tools for discovering and reading memory files. The
//! policy that tells a model when to use those tools is injected elsewhere.
pub mod backend;
pub mod local;
mod schema;
mod server;
pub use local::LocalMemoriesBackend;
pub use server::MemoriesMcpServer;
pub use server::run_stdio_server;
+457
View File
@@ -0,0 +1,457 @@
use crate::backend::DEFAULT_READ_MAX_TOKENS;
use crate::backend::ListMemoriesRequest;
use crate::backend::ListMemoriesResponse;
use crate::backend::MAX_LIST_RESULTS;
use crate::backend::MAX_SEARCH_RESULTS;
use crate::backend::MemoriesBackend;
use crate::backend::MemoriesBackendError;
use crate::backend::MemoryEntry;
use crate::backend::MemoryEntryType;
use crate::backend::MemorySearchMatch;
use crate::backend::ReadMemoryRequest;
use crate::backend::ReadMemoryResponse;
use crate::backend::SearchMatchMode;
use crate::backend::SearchMemoriesRequest;
use crate::backend::SearchMemoriesResponse;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_output_truncation::TruncationPolicy;
use codex_utils_output_truncation::truncate_text;
use std::borrow::Cow;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct LocalMemoriesBackend {
root: PathBuf,
}
impl LocalMemoriesBackend {
pub fn from_codex_home(codex_home: &AbsolutePathBuf) -> Self {
Self::from_memory_root(codex_home.join("memories").to_path_buf())
}
pub fn from_memory_root(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn root(&self) -> &Path {
&self.root
}
async fn resolve_scoped_path(
&self,
relative_path: Option<&str>,
) -> Result<PathBuf, MemoriesBackendError> {
let Some(relative_path) = relative_path else {
return Ok(self.root.clone());
};
let relative = Path::new(relative_path);
if relative.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
}) {
return Err(MemoriesBackendError::invalid_path(
relative_path,
"must stay within the memories root",
));
}
let components = relative.components().collect::<Vec<_>>();
let mut scoped_path = self.root.clone();
for (idx, component) in components.iter().enumerate() {
scoped_path.push(component.as_os_str());
let Some(metadata) = Self::metadata_or_none(&scoped_path).await? else {
for remaining_component in components.iter().skip(idx + 1) {
scoped_path.push(remaining_component.as_os_str());
}
return Ok(scoped_path);
};
reject_symlink(&display_relative_path(&self.root, &scoped_path), &metadata)?;
if idx + 1 < components.len() && !metadata.is_dir() {
return Err(MemoriesBackendError::invalid_path(
relative_path,
"traverses through a non-directory path component",
));
}
}
Ok(scoped_path)
}
async fn metadata_or_none(
path: &Path,
) -> Result<Option<std::fs::Metadata>, MemoriesBackendError> {
match tokio::fs::symlink_metadata(path).await {
Ok(metadata) => Ok(Some(metadata)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err.into()),
}
}
}
impl MemoriesBackend for LocalMemoriesBackend {
async fn list(
&self,
request: ListMemoriesRequest,
) -> Result<ListMemoriesResponse, MemoriesBackendError> {
let max_results = request.max_results.min(MAX_LIST_RESULTS);
let start = self.resolve_scoped_path(request.path.as_deref()).await?;
let start_index = match request.cursor.as_deref() {
Some(cursor) => cursor.parse::<usize>().map_err(|_| {
MemoriesBackendError::invalid_cursor(cursor, "must be a non-negative integer")
})?,
None => 0,
};
let Some(metadata) = Self::metadata_or_none(&start).await? else {
return Err(MemoriesBackendError::NotFound {
path: request.path.unwrap_or_default(),
});
};
reject_symlink(&display_relative_path(&self.root, &start), &metadata)?;
let mut entries = if metadata.is_file() {
vec![MemoryEntry {
path: display_relative_path(&self.root, &start),
entry_type: MemoryEntryType::File,
}]
} else if metadata.is_dir() {
let mut entries = Vec::new();
for path in read_sorted_dir_paths(&start).await? {
let Some(metadata) = Self::metadata_or_none(&path).await? else {
continue;
};
if metadata.file_type().is_symlink() {
continue;
}
let entry_type = if metadata.is_dir() {
MemoryEntryType::Directory
} else if metadata.is_file() {
MemoryEntryType::File
} else {
continue;
};
entries.push(MemoryEntry {
path: display_relative_path(&self.root, &path),
entry_type,
});
}
entries
} else {
Vec::new()
};
if start_index > entries.len() {
return Err(MemoriesBackendError::invalid_cursor(
start_index.to_string(),
"exceeds result count",
));
}
let end_index = start_index.saturating_add(max_results).min(entries.len());
let next_cursor = (end_index < entries.len()).then(|| end_index.to_string());
let truncated = next_cursor.is_some();
Ok(ListMemoriesResponse {
path: request.path,
entries: entries.drain(start_index..end_index).collect(),
next_cursor,
truncated,
})
}
async fn read(
&self,
request: ReadMemoryRequest,
) -> Result<ReadMemoryResponse, MemoriesBackendError> {
if request.line_offset == 0 {
return Err(MemoriesBackendError::InvalidLineOffset);
}
if request.max_lines == Some(0) {
return Err(MemoriesBackendError::InvalidMaxLines);
}
let path = self
.resolve_scoped_path(Some(request.path.as_str()))
.await?;
let Some(metadata) = Self::metadata_or_none(&path).await? else {
return Err(MemoriesBackendError::NotFound { path: request.path });
};
reject_symlink(&request.path, &metadata)?;
if !metadata.is_file() {
return Err(MemoriesBackendError::NotFile { path: request.path });
}
let original_content = tokio::fs::read_to_string(&path).await?;
let start_byte = line_start_byte_offset(&original_content, request.line_offset)?;
let end_byte = line_end_byte_offset(&original_content, start_byte, request.max_lines);
let content_from_offset = &original_content[start_byte..end_byte];
let max_tokens = if request.max_tokens == 0 {
DEFAULT_READ_MAX_TOKENS
} else {
request.max_tokens
};
let content = truncate_text(content_from_offset, TruncationPolicy::Tokens(max_tokens));
let truncated = end_byte < original_content.len() || content != content_from_offset;
Ok(ReadMemoryResponse {
path: request.path,
start_line_number: request.line_offset,
content,
truncated,
})
}
async fn search(
&self,
request: SearchMemoriesRequest,
) -> Result<SearchMemoriesResponse, MemoriesBackendError> {
let queries = request
.queries
.iter()
.map(|query| query.trim().to_string())
.collect::<Vec<_>>();
if queries.is_empty() || queries.iter().any(std::string::String::is_empty) {
return Err(MemoriesBackendError::EmptyQuery);
}
let max_results = request.max_results.min(MAX_SEARCH_RESULTS);
let start = self.resolve_scoped_path(request.path.as_deref()).await?;
let start_index = match request.cursor.as_deref() {
Some(cursor) => cursor.parse::<usize>().map_err(|_| {
MemoriesBackendError::invalid_cursor(cursor, "must be a non-negative integer")
})?,
None => 0,
};
let Some(metadata) = Self::metadata_or_none(&start).await? else {
return Err(MemoriesBackendError::NotFound {
path: request.path.unwrap_or_default(),
});
};
reject_symlink(&display_relative_path(&self.root, &start), &metadata)?;
let matcher =
SearchMatcher::new(queries.clone(), request.match_mode, request.case_sensitive);
let mut matches = Vec::new();
search_entries(
&self.root,
&start,
&metadata,
&matcher,
request.context_lines,
&mut matches,
)
.await?;
matches.sort_by(|left, right| {
left.path
.cmp(&right.path)
.then(left.match_line_number.cmp(&right.match_line_number))
});
if start_index > matches.len() {
return Err(MemoriesBackendError::invalid_cursor(
start_index.to_string(),
"exceeds result count",
));
}
let end_index = start_index.saturating_add(max_results).min(matches.len());
let next_cursor = (end_index < matches.len()).then(|| end_index.to_string());
let truncated = next_cursor.is_some();
Ok(SearchMemoriesResponse {
queries,
match_mode: request.match_mode,
path: request.path,
matches: matches.drain(start_index..end_index).collect(),
next_cursor,
truncated,
})
}
}
async fn search_entries(
root: &Path,
current: &Path,
current_metadata: &std::fs::Metadata,
matcher: &SearchMatcher,
context_lines: usize,
matches: &mut Vec<MemorySearchMatch>,
) -> Result<(), MemoriesBackendError> {
if current_metadata.is_file() {
search_file(root, current, matcher, context_lines, matches).await?;
return Ok(());
}
if !current_metadata.is_dir() {
return Ok(());
}
let mut pending = vec![current.to_path_buf()];
while let Some(dir_path) = pending.pop() {
for path in read_sorted_dir_paths(&dir_path).await? {
let Some(metadata) = LocalMemoriesBackend::metadata_or_none(&path).await? else {
continue;
};
if metadata.file_type().is_symlink() {
continue;
}
if metadata.is_dir() {
pending.push(path);
} else if metadata.is_file() {
search_file(root, &path, matcher, context_lines, matches).await?;
}
}
}
Ok(())
}
async fn search_file(
root: &Path,
path: &Path,
matcher: &SearchMatcher,
context_lines: usize,
matches: &mut Vec<MemorySearchMatch>,
) -> Result<(), MemoriesBackendError> {
let content = match tokio::fs::read_to_string(path).await {
Ok(content) => content,
Err(err) if err.kind() == std::io::ErrorKind::InvalidData => return Ok(()),
Err(err) => return Err(err.into()),
};
let lines = content.lines().collect::<Vec<_>>();
for (idx, line) in lines.iter().enumerate() {
let matched_queries = matcher.matched_queries(line);
if !matched_queries.is_empty() {
let start_index = idx.saturating_sub(context_lines);
let end_index = idx
.saturating_add(context_lines)
.saturating_add(1)
.min(lines.len());
matches.push(MemorySearchMatch {
path: display_relative_path(root, path),
match_line_number: idx + 1,
content_start_line_number: start_index + 1,
content: lines[start_index..end_index].join("\n"),
matched_queries,
});
}
}
Ok(())
}
struct SearchMatcher {
queries: Vec<String>,
normalized_queries: Option<Vec<String>>,
match_mode: SearchMatchMode,
}
impl SearchMatcher {
fn new(queries: Vec<String>, match_mode: SearchMatchMode, case_sensitive: bool) -> Self {
let normalized_queries = (!case_sensitive).then(|| {
queries
.iter()
.map(|query| query.to_lowercase())
.collect::<Vec<_>>()
});
Self {
queries,
normalized_queries,
match_mode,
}
}
fn matched_queries(&self, line: &str) -> Vec<String> {
let line = match self.normalized_queries.as_ref() {
Some(_) => Cow::Owned(line.to_lowercase()),
None => Cow::Borrowed(line),
};
let queries = self.normalized_queries.as_deref().unwrap_or(&self.queries);
let mut matched_queries = Vec::new();
for (idx, query) in queries.iter().enumerate() {
if line.as_ref().contains(query) {
matched_queries.push(self.queries[idx].clone());
}
}
match self.match_mode {
SearchMatchMode::Any => matched_queries,
SearchMatchMode::All if matched_queries.len() == self.queries.len() => matched_queries,
SearchMatchMode::All => Vec::new(),
}
}
}
async fn read_sorted_dir_paths(dir_path: &Path) -> Result<Vec<PathBuf>, MemoriesBackendError> {
let mut dir = match tokio::fs::read_dir(dir_path).await {
Ok(dir) => dir,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => return Err(err.into()),
};
let mut paths = Vec::new();
while let Some(entry) = dir.next_entry().await? {
paths.push(entry.path());
}
paths.sort();
Ok(paths)
}
fn reject_symlink(path: &str, metadata: &std::fs::Metadata) -> Result<(), MemoriesBackendError> {
if metadata.file_type().is_symlink() {
return Err(MemoriesBackendError::invalid_path(
path,
"must not be a symlink",
));
}
Ok(())
}
fn display_relative_path(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.components()
.map(|component| component.as_os_str().to_string_lossy())
.filter(|component| !component.is_empty())
.collect::<Vec<_>>()
.join("/")
}
fn line_start_byte_offset(
content: &str,
line_offset: usize,
) -> Result<usize, MemoriesBackendError> {
if line_offset == 1 {
return Ok(0);
}
let mut current_line = 1;
for (idx, ch) in content.char_indices() {
if ch == '\n' {
current_line += 1;
if current_line == line_offset {
return Ok(idx + 1);
}
}
}
Err(MemoriesBackendError::LineOffsetExceedsFileLength)
}
fn line_end_byte_offset(content: &str, start_byte: usize, max_lines: Option<usize>) -> usize {
let Some(max_lines) = max_lines else {
return content.len();
};
let mut lines_seen = 1;
for (relative_idx, ch) in content[start_byte..].char_indices() {
if ch == '\n' {
if lines_seen == max_lines {
return start_byte + relative_idx + 1;
}
lines_seen += 1;
}
}
content.len()
}
#[cfg(test)]
#[path = "local_tests.rs"]
mod tests;
+881
View File
@@ -0,0 +1,881 @@
use super::*;
use crate::backend::DEFAULT_LIST_MAX_RESULTS;
use crate::backend::DEFAULT_SEARCH_MAX_RESULTS;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
fn backend(tempdir: &TempDir) -> LocalMemoriesBackend {
LocalMemoriesBackend::from_memory_root(tempdir.path())
}
fn search_request(queries: &[&str]) -> SearchMemoriesRequest {
SearchMemoriesRequest {
queries: queries.iter().map(|query| (*query).to_string()).collect(),
match_mode: SearchMatchMode::Any,
path: None,
cursor: None,
context_lines: 0,
case_sensitive: true,
max_results: DEFAULT_SEARCH_MAX_RESULTS,
}
}
#[tokio::test]
async fn list_returns_shallow_memory_paths() {
let tempdir = TempDir::new().expect("tempdir");
tokio::fs::create_dir_all(tempdir.path().join("skills/example"))
.await
.expect("create skills dir");
tokio::fs::write(tempdir.path().join("MEMORY.md"), "summary")
.await
.expect("write memory file");
tokio::fs::write(tempdir.path().join("skills/example/SKILL.md"), "skill")
.await
.expect("write skill file");
let response = backend(&tempdir)
.list(ListMemoriesRequest {
path: None,
cursor: None,
max_results: DEFAULT_LIST_MAX_RESULTS,
})
.await
.expect("list memories");
assert_eq!(
response.entries,
vec![
MemoryEntry {
path: "MEMORY.md".to_string(),
entry_type: MemoryEntryType::File,
},
MemoryEntry {
path: "skills".to_string(),
entry_type: MemoryEntryType::Directory,
},
]
);
assert_eq!(response.next_cursor, None);
assert_eq!(response.truncated, false);
}
#[tokio::test]
async fn list_supports_pagination() {
let tempdir = TempDir::new().expect("tempdir");
tokio::fs::create_dir_all(tempdir.path().join("skills"))
.await
.expect("create skills dir");
tokio::fs::create_dir_all(tempdir.path().join("rollout_summaries"))
.await
.expect("create rollout dir");
tokio::fs::write(tempdir.path().join("MEMORY.md"), "summary")
.await
.expect("write memory file");
tokio::fs::write(tempdir.path().join("memory_summary.md"), "summary")
.await
.expect("write memory summary");
let page1 = backend(&tempdir)
.list(ListMemoriesRequest {
path: None,
cursor: None,
max_results: 2,
})
.await
.expect("list first page");
assert_eq!(
page1.entries,
vec![
MemoryEntry {
path: "MEMORY.md".to_string(),
entry_type: MemoryEntryType::File,
},
MemoryEntry {
path: "memory_summary.md".to_string(),
entry_type: MemoryEntryType::File,
},
]
);
assert_eq!(page1.next_cursor.as_deref(), Some("2"));
assert_eq!(page1.truncated, true);
let page2 = backend(&tempdir)
.list(ListMemoriesRequest {
path: None,
cursor: page1.next_cursor,
max_results: 2,
})
.await
.expect("list second page");
assert_eq!(
page2.entries,
vec![
MemoryEntry {
path: "rollout_summaries".to_string(),
entry_type: MemoryEntryType::Directory,
},
MemoryEntry {
path: "skills".to_string(),
entry_type: MemoryEntryType::Directory,
},
]
);
assert_eq!(page2.next_cursor, None);
assert_eq!(page2.truncated, false);
}
#[tokio::test]
async fn list_preserves_lexicographic_order_for_siblings() {
let tempdir = TempDir::new().expect("tempdir");
tokio::fs::create_dir_all(tempdir.path().join("a"))
.await
.expect("create a dir");
tokio::fs::write(tempdir.path().join("a.txt"), "a")
.await
.expect("write a.txt file");
tokio::fs::write(tempdir.path().join("b.txt"), "b")
.await
.expect("write b file");
let response = backend(&tempdir)
.list(ListMemoriesRequest {
path: None,
cursor: None,
max_results: DEFAULT_LIST_MAX_RESULTS,
})
.await
.expect("list memories");
assert_eq!(
response
.entries
.iter()
.map(|entry| entry.path.as_str())
.collect::<Vec<_>>(),
vec!["a", "a.txt", "b.txt"]
);
}
#[tokio::test]
async fn list_scoped_directory_is_shallow() {
let tempdir = TempDir::new().expect("tempdir");
tokio::fs::create_dir_all(tempdir.path().join("skills/example"))
.await
.expect("create nested skills dir");
tokio::fs::write(tempdir.path().join("skills/README.md"), "readme")
.await
.expect("write skills readme");
tokio::fs::write(tempdir.path().join("skills/example/SKILL.md"), "skill")
.await
.expect("write nested skill file");
let response = backend(&tempdir)
.list(ListMemoriesRequest {
path: Some("skills".to_string()),
cursor: None,
max_results: DEFAULT_LIST_MAX_RESULTS,
})
.await
.expect("list scoped directory");
assert_eq!(
response.entries,
vec![
MemoryEntry {
path: "skills/README.md".to_string(),
entry_type: MemoryEntryType::File,
},
MemoryEntry {
path: "skills/example".to_string(),
entry_type: MemoryEntryType::Directory,
},
]
);
}
#[tokio::test]
async fn list_rejects_invalid_cursor() {
let tempdir = TempDir::new().expect("tempdir");
tokio::fs::write(tempdir.path().join("MEMORY.md"), "summary")
.await
.expect("write memory file");
let err = backend(&tempdir)
.list(ListMemoriesRequest {
path: None,
cursor: Some("bogus".to_string()),
max_results: DEFAULT_LIST_MAX_RESULTS,
})
.await
.expect_err("cursor should be rejected");
assert!(matches!(err, MemoriesBackendError::InvalidCursor { .. }));
}
#[tokio::test]
async fn list_rejects_cursor_past_end() {
let tempdir = TempDir::new().expect("tempdir");
tokio::fs::write(tempdir.path().join("MEMORY.md"), "summary")
.await
.expect("write memory file");
let err = backend(&tempdir)
.list(ListMemoriesRequest {
path: None,
cursor: Some("2".to_string()),
max_results: DEFAULT_LIST_MAX_RESULTS,
})
.await
.expect_err("cursor past end should be rejected");
assert!(matches!(err, MemoriesBackendError::InvalidCursor { .. }));
}
#[tokio::test]
async fn read_rejects_directory_and_returns_file_content() {
let tempdir = TempDir::new().expect("tempdir");
tokio::fs::write(tempdir.path().join("MEMORY.md"), "remember this")
.await
.expect("write memory file");
let response = backend(&tempdir)
.read(ReadMemoryRequest {
path: "MEMORY.md".to_string(),
line_offset: 1,
max_lines: None,
max_tokens: DEFAULT_READ_MAX_TOKENS,
})
.await
.expect("read memory");
assert_eq!(
response,
ReadMemoryResponse {
path: "MEMORY.md".to_string(),
start_line_number: 1,
content: "remember this".to_string(),
truncated: false,
}
);
let err = backend(&tempdir)
.read(ReadMemoryRequest {
path: ".".to_string(),
line_offset: 1,
max_lines: None,
max_tokens: DEFAULT_READ_MAX_TOKENS,
})
.await
.expect_err("directory should not be readable as file");
assert!(matches!(err, MemoriesBackendError::NotFile { .. }));
}
#[tokio::test]
async fn read_rejects_missing_paths() {
let tempdir = TempDir::new().expect("tempdir");
let err = backend(&tempdir)
.read(ReadMemoryRequest {
path: "missing.md".to_string(),
line_offset: 1,
max_lines: None,
max_tokens: DEFAULT_READ_MAX_TOKENS,
})
.await
.expect_err("missing files should be rejected");
assert!(matches!(err, MemoriesBackendError::NotFound { .. }));
}
#[tokio::test]
async fn read_supports_line_offset() {
let tempdir = TempDir::new().expect("tempdir");
tokio::fs::write(tempdir.path().join("MEMORY.md"), "alpha\nbeta\ngamma\n")
.await
.expect("write memory file");
let response = backend(&tempdir)
.read(ReadMemoryRequest {
path: "MEMORY.md".to_string(),
line_offset: 2,
max_lines: None,
max_tokens: DEFAULT_READ_MAX_TOKENS,
})
.await
.expect("read memory from line offset");
assert_eq!(
response,
ReadMemoryResponse {
path: "MEMORY.md".to_string(),
start_line_number: 2,
content: "beta\ngamma\n".to_string(),
truncated: false,
}
);
}
#[tokio::test]
async fn read_supports_max_lines() {
let tempdir = TempDir::new().expect("tempdir");
tokio::fs::write(tempdir.path().join("MEMORY.md"), "alpha\nbeta\ngamma\n")
.await
.expect("write memory file");
let response = backend(&tempdir)
.read(ReadMemoryRequest {
path: "MEMORY.md".to_string(),
line_offset: 2,
max_lines: Some(1),
max_tokens: DEFAULT_READ_MAX_TOKENS,
})
.await
.expect("read memory with line limit");
assert_eq!(
response,
ReadMemoryResponse {
path: "MEMORY.md".to_string(),
start_line_number: 2,
content: "beta\n".to_string(),
truncated: true,
}
);
}
#[tokio::test]
async fn read_rejects_invalid_line_requests() {
let tempdir = TempDir::new().expect("tempdir");
tokio::fs::write(tempdir.path().join("MEMORY.md"), "only\n")
.await
.expect("write memory file");
let zero_offset_err = backend(&tempdir)
.read(ReadMemoryRequest {
path: "MEMORY.md".to_string(),
line_offset: 0,
max_lines: None,
max_tokens: DEFAULT_READ_MAX_TOKENS,
})
.await
.expect_err("zero line offset should fail");
assert!(matches!(
zero_offset_err,
MemoriesBackendError::InvalidLineOffset
));
let zero_max_lines_err = backend(&tempdir)
.read(ReadMemoryRequest {
path: "MEMORY.md".to_string(),
line_offset: 1,
max_lines: Some(0),
max_tokens: DEFAULT_READ_MAX_TOKENS,
})
.await
.expect_err("zero max lines should fail");
assert!(matches!(
zero_max_lines_err,
MemoriesBackendError::InvalidMaxLines
));
let past_end_err = backend(&tempdir)
.read(ReadMemoryRequest {
path: "MEMORY.md".to_string(),
line_offset: 3,
max_lines: None,
max_tokens: DEFAULT_READ_MAX_TOKENS,
})
.await
.expect_err("line offset past end should fail");
assert!(matches!(
past_end_err,
MemoriesBackendError::LineOffsetExceedsFileLength
));
}
#[tokio::test]
async fn search_supports_directory_and_file_scopes() {
let tempdir = TempDir::new().expect("tempdir");
tokio::fs::create_dir_all(tempdir.path().join("rollout_summaries"))
.await
.expect("create rollout summaries dir");
tokio::fs::write(tempdir.path().join("MEMORY.md"), "alpha\nneedle\n")
.await
.expect("write memory file");
tokio::fs::write(
tempdir.path().join("rollout_summaries/a.jsonl"),
"needle again\n",
)
.await
.expect("write rollout summary");
let response = backend(&tempdir)
.search(search_request(&["needle"]))
.await
.expect("search all memories");
assert_eq!(
response.matches,
vec![
MemorySearchMatch {
path: "MEMORY.md".to_string(),
match_line_number: 2,
content_start_line_number: 2,
content: "needle".to_string(),
matched_queries: vec!["needle".to_string()],
},
MemorySearchMatch {
path: "rollout_summaries/a.jsonl".to_string(),
match_line_number: 1,
content_start_line_number: 1,
content: "needle again".to_string(),
matched_queries: vec!["needle".to_string()],
},
]
);
assert_eq!(response.next_cursor, None);
assert_eq!(response.truncated, false);
let mut request = search_request(&["needle"]);
request.path = Some("MEMORY.md".to_string());
let file_response = backend(&tempdir)
.search(request)
.await
.expect("search one memory file");
assert_eq!(
file_response.matches,
vec![MemorySearchMatch {
path: "MEMORY.md".to_string(),
match_line_number: 2,
content_start_line_number: 2,
content: "needle".to_string(),
matched_queries: vec!["needle".to_string()],
}]
);
assert_eq!(file_response.next_cursor, None);
assert_eq!(file_response.truncated, false);
}
#[tokio::test]
async fn search_supports_pagination() {
let tempdir = TempDir::new().expect("tempdir");
tokio::fs::create_dir_all(tempdir.path().join("rollout_summaries"))
.await
.expect("create rollout summaries dir");
tokio::fs::write(tempdir.path().join("MEMORY.md"), "needle one\nneedle two\n")
.await
.expect("write memory file");
tokio::fs::write(
tempdir.path().join("rollout_summaries/a.jsonl"),
"needle three\n",
)
.await
.expect("write rollout summary");
let mut page1_request = search_request(&["needle"]);
page1_request.max_results = 2;
let page1 = backend(&tempdir)
.search(page1_request)
.await
.expect("search first page");
assert_eq!(
page1.matches,
vec![
MemorySearchMatch {
path: "MEMORY.md".to_string(),
match_line_number: 1,
content_start_line_number: 1,
content: "needle one".to_string(),
matched_queries: vec!["needle".to_string()],
},
MemorySearchMatch {
path: "MEMORY.md".to_string(),
match_line_number: 2,
content_start_line_number: 2,
content: "needle two".to_string(),
matched_queries: vec!["needle".to_string()],
},
]
);
assert_eq!(page1.next_cursor.as_deref(), Some("2"));
assert_eq!(page1.truncated, true);
let mut page2_request = search_request(&["needle"]);
page2_request.cursor = page1.next_cursor;
page2_request.max_results = 2;
let page2 = backend(&tempdir)
.search(page2_request)
.await
.expect("search second page");
assert_eq!(
page2.matches,
vec![MemorySearchMatch {
path: "rollout_summaries/a.jsonl".to_string(),
match_line_number: 1,
content_start_line_number: 1,
content: "needle three".to_string(),
matched_queries: vec!["needle".to_string()],
}]
);
assert_eq!(page2.next_cursor, None);
assert_eq!(page2.truncated, false);
}
#[tokio::test]
async fn search_preserves_global_lexicographic_path_order() {
let tempdir = TempDir::new().expect("tempdir");
tokio::fs::create_dir_all(tempdir.path().join("a"))
.await
.expect("create nested dir");
tokio::fs::write(tempdir.path().join("a/child.md"), "needle in child\n")
.await
.expect("write nested file");
tokio::fs::write(tempdir.path().join("a.txt"), "needle in sibling\n")
.await
.expect("write sibling file");
let response = backend(&tempdir)
.search(search_request(&["needle"]))
.await
.expect("search memories");
assert_eq!(
response.matches,
vec![
MemorySearchMatch {
path: "a.txt".to_string(),
match_line_number: 1,
content_start_line_number: 1,
content: "needle in sibling".to_string(),
matched_queries: vec!["needle".to_string()],
},
MemorySearchMatch {
path: "a/child.md".to_string(),
match_line_number: 1,
content_start_line_number: 1,
content: "needle in child".to_string(),
matched_queries: vec!["needle".to_string()],
},
]
);
}
#[tokio::test]
async fn search_supports_context_lines() {
let tempdir = TempDir::new().expect("tempdir");
tokio::fs::write(
tempdir.path().join("MEMORY.md"),
"alpha\nneedle\nomega\nneedle again\n",
)
.await
.expect("write memory file");
let mut request = search_request(&["needle"]);
request.context_lines = 1;
let response = backend(&tempdir)
.search(request)
.await
.expect("search with context");
assert_eq!(
response.matches,
vec![
MemorySearchMatch {
path: "MEMORY.md".to_string(),
match_line_number: 2,
content_start_line_number: 1,
content: "alpha\nneedle\nomega".to_string(),
matched_queries: vec!["needle".to_string()],
},
MemorySearchMatch {
path: "MEMORY.md".to_string(),
match_line_number: 4,
content_start_line_number: 3,
content: "omega\nneedle again".to_string(),
matched_queries: vec!["needle".to_string()],
},
]
);
}
#[tokio::test]
async fn search_supports_case_insensitive_matching() {
let tempdir = TempDir::new().expect("tempdir");
tokio::fs::write(tempdir.path().join("MEMORY.md"), "Needle\nneedle\nNEEDLE\n")
.await
.expect("write memory file");
let sensitive_response = backend(&tempdir)
.search(search_request(&["needle"]))
.await
.expect("search with case-sensitive matching");
assert_eq!(
sensitive_response.matches,
vec![MemorySearchMatch {
path: "MEMORY.md".to_string(),
match_line_number: 2,
content_start_line_number: 2,
content: "needle".to_string(),
matched_queries: vec!["needle".to_string()],
}]
);
let mut request = search_request(&["needle"]);
request.case_sensitive = false;
let insensitive_response = backend(&tempdir)
.search(request)
.await
.expect("search with case-insensitive matching");
assert_eq!(
insensitive_response.matches,
vec![
MemorySearchMatch {
path: "MEMORY.md".to_string(),
match_line_number: 1,
content_start_line_number: 1,
content: "Needle".to_string(),
matched_queries: vec!["needle".to_string()],
},
MemorySearchMatch {
path: "MEMORY.md".to_string(),
match_line_number: 2,
content_start_line_number: 2,
content: "needle".to_string(),
matched_queries: vec!["needle".to_string()],
},
MemorySearchMatch {
path: "MEMORY.md".to_string(),
match_line_number: 3,
content_start_line_number: 3,
content: "NEEDLE".to_string(),
matched_queries: vec!["needle".to_string()],
},
]
);
}
#[tokio::test]
async fn search_supports_any_and_all_match_modes() {
let tempdir = TempDir::new().expect("tempdir");
tokio::fs::write(
tempdir.path().join("MEMORY.md"),
"alpha needle beta\nalpha only\nneedle only\n",
)
.await
.expect("write memory file");
let any_response = backend(&tempdir)
.search(search_request(&["alpha", "needle"]))
.await
.expect("search with any match mode");
assert_eq!(
any_response.matches,
vec![
MemorySearchMatch {
path: "MEMORY.md".to_string(),
match_line_number: 1,
content_start_line_number: 1,
content: "alpha needle beta".to_string(),
matched_queries: vec!["alpha".to_string(), "needle".to_string()],
},
MemorySearchMatch {
path: "MEMORY.md".to_string(),
match_line_number: 2,
content_start_line_number: 2,
content: "alpha only".to_string(),
matched_queries: vec!["alpha".to_string()],
},
MemorySearchMatch {
path: "MEMORY.md".to_string(),
match_line_number: 3,
content_start_line_number: 3,
content: "needle only".to_string(),
matched_queries: vec!["needle".to_string()],
},
]
);
let mut request = search_request(&["alpha", "needle"]);
request.match_mode = SearchMatchMode::All;
let all_response = backend(&tempdir)
.search(request)
.await
.expect("search with all match mode");
assert_eq!(
all_response.matches,
vec![MemorySearchMatch {
path: "MEMORY.md".to_string(),
match_line_number: 1,
content_start_line_number: 1,
content: "alpha needle beta".to_string(),
matched_queries: vec!["alpha".to_string(), "needle".to_string()],
}]
);
}
#[tokio::test]
async fn search_rejects_invalid_cursor() {
let tempdir = TempDir::new().expect("tempdir");
tokio::fs::write(tempdir.path().join("MEMORY.md"), "needle\n")
.await
.expect("write memory file");
let mut request = search_request(&["needle"]);
request.cursor = Some("bogus".to_string());
let err = backend(&tempdir)
.search(request)
.await
.expect_err("cursor should be rejected");
assert!(matches!(err, MemoriesBackendError::InvalidCursor { .. }));
let mut request = search_request(&["needle"]);
request.cursor = Some("2".to_string());
let past_end_err = backend(&tempdir)
.search(request)
.await
.expect_err("cursor past end should be rejected");
assert!(matches!(
past_end_err,
MemoriesBackendError::InvalidCursor { .. }
));
}
#[tokio::test]
async fn list_rejects_missing_scoped_paths() {
let tempdir = TempDir::new().expect("tempdir");
let err = backend(&tempdir)
.list(ListMemoriesRequest {
path: Some("missing".to_string()),
cursor: None,
max_results: DEFAULT_LIST_MAX_RESULTS,
})
.await
.expect_err("missing scoped paths should be rejected");
assert!(matches!(err, MemoriesBackendError::NotFound { .. }));
}
#[tokio::test]
async fn search_rejects_missing_scoped_paths() {
let tempdir = TempDir::new().expect("tempdir");
let mut request = search_request(&["needle"]);
request.path = Some("missing".to_string());
let err = backend(&tempdir)
.search(request)
.await
.expect_err("missing scoped paths should be rejected");
assert!(matches!(err, MemoriesBackendError::NotFound { .. }));
}
#[tokio::test]
async fn scoped_paths_reject_parent_segments() {
let tempdir = TempDir::new().expect("tempdir");
let err = backend(&tempdir)
.read(ReadMemoryRequest {
path: "../secret".to_string(),
line_offset: 1,
max_lines: None,
max_tokens: DEFAULT_READ_MAX_TOKENS,
})
.await
.expect_err("parent traversal should fail");
assert!(matches!(err, MemoriesBackendError::InvalidPath { .. }));
}
#[cfg(unix)]
#[tokio::test]
async fn read_rejects_symlinked_files() {
let tempdir = TempDir::new().expect("tempdir");
let outside = tempdir.path().join("outside.txt");
tokio::fs::write(&outside, "outside")
.await
.expect("write outside file");
std::os::unix::fs::symlink(&outside, tempdir.path().join("inside-link"))
.expect("create symlink");
let err = backend(&tempdir)
.read(ReadMemoryRequest {
path: "inside-link".to_string(),
line_offset: 1,
max_lines: None,
max_tokens: DEFAULT_READ_MAX_TOKENS,
})
.await
.expect_err("symlink should be rejected");
assert!(matches!(err, MemoriesBackendError::InvalidPath { .. }));
}
#[cfg(unix)]
#[tokio::test]
async fn read_rejects_symlinked_ancestor_directories() {
let tempdir = TempDir::new().expect("tempdir");
let outside = tempdir.path().join("outside");
tokio::fs::create_dir_all(&outside)
.await
.expect("create outside dir");
tokio::fs::write(outside.join("secret.md"), "outside secret")
.await
.expect("write outside file");
std::os::unix::fs::symlink(&outside, tempdir.path().join("skills")).expect("create symlink");
let err = backend(&tempdir)
.read(ReadMemoryRequest {
path: "skills/secret.md".to_string(),
line_offset: 1,
max_lines: None,
max_tokens: DEFAULT_READ_MAX_TOKENS,
})
.await
.expect_err("symlinked ancestors should be rejected");
assert!(matches!(err, MemoriesBackendError::InvalidPath { .. }));
}
#[cfg(unix)]
#[tokio::test]
async fn list_rejects_symlinked_directories() {
let tempdir = TempDir::new().expect("tempdir");
let outside = tempdir.path().join("outside");
tokio::fs::create_dir_all(&outside)
.await
.expect("create outside dir");
std::os::unix::fs::symlink(&outside, tempdir.path().join("skills")).expect("create symlink");
let err = backend(&tempdir)
.list(ListMemoriesRequest {
path: Some("skills".to_string()),
cursor: None,
max_results: DEFAULT_LIST_MAX_RESULTS,
})
.await
.expect_err("symlinked directories should be rejected");
assert!(matches!(err, MemoriesBackendError::InvalidPath { .. }));
}
#[cfg(unix)]
#[tokio::test]
async fn search_rejects_symlinked_directories() {
let tempdir = TempDir::new().expect("tempdir");
let outside = tempdir.path().join("outside");
tokio::fs::create_dir_all(&outside)
.await
.expect("create outside dir");
tokio::fs::write(outside.join("secret.md"), "needle")
.await
.expect("write outside file");
std::os::unix::fs::symlink(&outside, tempdir.path().join("skills")).expect("create symlink");
let mut request = search_request(&["needle"]);
request.path = Some("skills".to_string());
let err = backend(&tempdir)
.search(request)
.await
.expect_err("symlinked directories should be rejected");
assert!(matches!(err, MemoriesBackendError::InvalidPath { .. }));
}
+42
View File
@@ -0,0 +1,42 @@
use rmcp::model::JsonObject;
use schemars::JsonSchema;
use schemars::r#gen::SchemaSettings;
pub(crate) fn input_schema_for<T: JsonSchema>() -> JsonObject {
schema_for::<T>(/*option_add_null_type*/ false)
}
pub(crate) fn output_schema_for<T: JsonSchema>() -> JsonObject {
schema_for::<T>(/*option_add_null_type*/ true)
}
fn schema_for<T: JsonSchema>(option_add_null_type: bool) -> JsonObject {
let schema = SchemaSettings::draft2019_09()
.with(|settings| {
settings.inline_subschemas = true;
settings.option_add_null_type = option_add_null_type;
})
.into_generator()
.into_root_schema_for::<T>();
let schema_value = serde_json::to_value(schema)
.unwrap_or_else(|err| panic!("generated tool schema should serialize: {err}"));
let serde_json::Value::Object(mut schema_object) = schema_value else {
unreachable!("root tool schema must be an object");
};
// MCP tools only need the JSON Schema body, not schemars' root metadata.
let mut tool_schema = JsonObject::new();
for key in [
"properties",
"required",
"type",
"additionalProperties",
"$defs",
"definitions",
] {
if let Some(value) = schema_object.remove(key) {
tool_schema.insert(key.to_string(), value);
}
}
tool_schema
}

Some files were not shown because too many files have changed in this diff Show More