Merge branch 'canvnro/codex-pac-pr2-contract' into canvnro/codex-pac-pr3-win

This commit is contained in:
canvrno-oai
2026-06-11 14:09:22 -07:00
committed by GitHub
410 changed files with 18679 additions and 6510 deletions
+26 -22
View File
@@ -1103,31 +1103,21 @@ jobs:
echo "path=${notes_path}" >> "${GITHUB_OUTPUT}"
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- name: Download target artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: dist
pattern: "{aarch64,x86_64}-{apple-darwin{,-app-server},unknown-linux-musl{,-app-server},pc-windows-msvc}"
- name: Download supplemental release artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: dist
pattern: "{*-symbols,argument-comment-lint-*,codex-zsh-*,python-runtime-wheel-*}"
- name: List
run: ls -R dist/
- name: Delete entries from dist/ that should not go in the release
run: |
rm -rf dist/windows-binaries*
rm -rf dist/*-apple-darwin*-signed-binaries
rm -rf dist/*-apple-darwin*-packaged
rm -rf dist/*-apple-darwin*-unsigned-dmg
rm -rf dist/*-apple-darwin*-signed-dmg
rm -rf dist/*-apple-darwin*-binary-signing-verification
rm -rf dist/*-apple-darwin*-dmg-signing-verification
rm -rf dist/*-apple-darwin*-unsigned
# cargo-timing.html appears under multiple target-specific directories.
# If included in files: dist/**, release upload races on duplicate
# asset names and can fail with 404s.
find dist -type f -name 'cargo-timing.html' -delete
find dist -type d -empty -delete
ls -R dist/
- name: Add Codex package checksum manifest
run: |
set -euo pipefail
@@ -1204,6 +1194,7 @@ jobs:
./scripts/stage_npm_packages.py \
--release-version "$RELEASE_VERSION" \
--workflow-url "$workflow_url" \
--artifacts-dir "${GITHUB_WORKSPACE}/dist" \
--package codex \
--package codex-responses-api-proxy \
--package codex-sdk
@@ -1226,6 +1217,18 @@ jobs:
# (e.g. -alpha, -beta). Otherwise publish a normal release.
prerelease: ${{ contains(steps.release_name.outputs.name, '-') }}
publish-dotslash:
name: publish-dotslash
needs: release
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -1354,14 +1357,15 @@ jobs:
other_tarballs+=("${tarball}")
done
# Publish the platform packages before the root CLI wrapper. The root
# wrapper advances @openai/codex@latest, so it should only publish
# after the optional dependency versions it references exist.
# npm returns HTTP 409 when concurrent publishes update the same
# packument. Every platform tarball is a version of @openai/codex,
# so publish all tarballs serially.
tarballs=(
"${platform_tarballs[@]}"
"${other_tarballs[@]}"
"${root_tarball}"
)
# The SDK depends on this exact root package version.
if [[ -f "${sdk_tarball}" ]]; then
tarballs+=("${sdk_tarball}")
fi
+1
View File
@@ -14,6 +14,7 @@ In the codex-rs folder where the rust code lives:
- Avoid bool or ambiguous `Option` parameters that force callers to write hard-to-read code such as `foo(false)` or `bar(None)`. Prefer enums, named methods, newtypes, or other idiomatic Rust API shapes when they keep the callsite self-documenting.
- When you cannot make that API change and still need a small positional-literal callsite in Rust, follow the `argument_comment_lint` convention:
- Use an exact `/*param_name*/` comment before opaque literal arguments such as `None`, booleans, and numeric literals when passing them by position.
- A method's sole non-self argument is exempt when the method and parameter names match, such as `.enabled(false)` for `fn enabled(&self, enabled: bool)`.
- Do not add these comments for string or char literals unless the comment adds real clarity; those literals are intentionally exempt from the lint.
- The parameter name in the comment must exactly match the callee signature.
- You can run `just argument-comment-lint` to run the lint check locally. This is powered by Bazel, so running it the first time can be slow if Bazel is not warmed up, though incremental invocations should take <15s. Most of the time, it is best to update the PR and let CI take responsibility for checking this (or run it asynchronously in the background after submitting the PR). Note CI checks all three platforms, which the local run does not.
+7
View File
@@ -11,6 +11,7 @@ single_version_override(
patch_strip = 1,
patches = [
"//patches:llvm_rusty_v8_custom_libcxx.patch",
"//patches:llvm_windows_arm64_powl.patch",
"//patches:llvm_windows_symlink_extract.patch",
],
)
@@ -94,6 +95,7 @@ single_version_override(
module_name = "rules_rs",
patch_strip = 1,
patches = [
"//patches:rules_rs_build_script_deps_annotation.patch",
"//patches:rules_rs_windows_gnullvm_exec.patch",
"//patches:rules_rs_windows_exec_linker.patch",
],
@@ -295,6 +297,11 @@ bazel_dep(name = "openssl", version = "3.5.4.bcr.0")
inject_repo(crate, "xz")
crate.annotation(
# Build scripts compile in Bazel's exec configuration, so target-specific
# optional build deps are otherwise dropped for the musl release platforms.
build_script_deps = [
"@crates//:openssl-src-300.5.5+3.5.5",
],
build_script_data = [
"@openssl//:gen_dir",
],
+1
View File
@@ -0,0 +1 @@
# Release platform rules live in this package.
+27
View File
@@ -0,0 +1,27 @@
"""Rules for building release binaries across supported target platforms."""
load("@rules_platform//platform_data:defs.bzl", "platform_data")
PLATFORMS = [
"linux_arm64_musl",
"linux_amd64_musl",
"macos_amd64",
"macos_arm64",
"windows_amd64",
"windows_arm64",
]
def multiplatform_binaries(name, platforms = PLATFORMS):
for platform in platforms:
platform_data(
name = name + "_" + platform,
platform = "@llvm//platforms:" + platform,
target = name,
tags = ["manual"],
)
native.filegroup(
name = "release_binaries",
srcs = [name + "_" + platform for platform in platforms],
tags = ["manual"],
)
+37 -1
View File
@@ -1979,6 +1979,7 @@ dependencies = [
"codex-git-utils",
"codex-goal-extension",
"codex-guardian",
"codex-home",
"codex-hooks",
"codex-image-generation-extension",
"codex-login",
@@ -2004,6 +2005,7 @@ dependencies = [
"codex-utils-cargo-bin",
"codex-utils-cli",
"codex-utils-json-to-toml",
"codex-utils-path-uri",
"codex-utils-pty",
"codex-web-search-extension",
"codex-windows-sandbox",
@@ -2183,6 +2185,7 @@ dependencies = [
"codex-exec-server",
"codex-utils-absolute-path",
"codex-utils-cargo-bin",
"codex-utils-path-uri",
"pretty_assertions",
"similar",
"tempfile",
@@ -2319,6 +2322,7 @@ dependencies = [
"codex-execpolicy",
"codex-features",
"codex-git-utils",
"codex-home",
"codex-install-context",
"codex-login",
"codex-mcp",
@@ -2517,6 +2521,7 @@ dependencies = [
"codex-protocol",
"codex-utils-absolute-path",
"codex-utils-path",
"codex-utils-path-uri",
"core-foundation 0.9.4",
"dns-lookup",
"dunce",
@@ -2603,6 +2608,7 @@ dependencies = [
"codex-features",
"codex-feedback",
"codex-git-utils",
"codex-home",
"codex-hooks",
"codex-image-generation-extension",
"codex-install-context",
@@ -2636,6 +2642,7 @@ dependencies = [
"codex-utils-image",
"codex-utils-output-truncation",
"codex-utils-path",
"codex-utils-path-uri",
"codex-utils-plugins",
"codex-utils-pty",
"codex-utils-stream-parser",
@@ -2706,6 +2713,7 @@ dependencies = [
"codex-exec-server",
"codex-extension-api",
"codex-features",
"codex-home",
"codex-login",
"codex-model-provider-info",
"codex-models-manager",
@@ -2770,6 +2778,7 @@ dependencies = [
"codex-skills",
"codex-utils-absolute-path",
"codex-utils-output-truncation",
"codex-utils-path-uri",
"codex-utils-plugins",
"dirs",
"dunce",
@@ -2850,6 +2859,7 @@ dependencies = [
"codex-shell-command",
"codex-test-binary-support",
"codex-utils-absolute-path",
"codex-utils-path-uri",
"codex-utils-pty",
"codex-utils-rustls-provider",
"ctor 0.6.3",
@@ -2927,6 +2937,7 @@ dependencies = [
"codex-context-fragments",
"codex-protocol",
"codex-tools",
"codex-utils-absolute-path",
"pretty_assertions",
"tokio",
]
@@ -3006,6 +3017,7 @@ dependencies = [
"async-trait",
"codex-protocol",
"codex-utils-absolute-path",
"codex-utils-path-uri",
"serde",
]
@@ -3029,6 +3041,7 @@ dependencies = [
"codex-file-system",
"codex-protocol",
"codex-utils-absolute-path",
"codex-utils-path-uri",
"futures",
"gix",
"once_cell",
@@ -3075,6 +3088,17 @@ dependencies = [
"codex-protocol",
]
[[package]]
name = "codex-home"
version = "0.0.0"
dependencies = [
"codex-extension-api",
"codex-utils-absolute-path",
"pretty_assertions",
"tempfile",
"tokio",
]
[[package]]
name = "codex-hooks"
version = "0.0.0"
@@ -3222,6 +3246,7 @@ name = "codex-mcp"
version = "0.0.0"
dependencies = [
"anyhow",
"arc-swap",
"async-channel",
"codex-api",
"codex-async-utils",
@@ -3275,6 +3300,7 @@ dependencies = [
"codex-core",
"codex-exec-server",
"codex-extension-api",
"codex-home",
"codex-login",
"codex-protocol",
"codex-shell-command",
@@ -3816,15 +3842,22 @@ name = "codex-skills-extension"
version = "0.0.0"
dependencies = [
"async-trait",
"codex-core",
"codex-core-skills",
"codex-exec-server",
"codex-extension-api",
"codex-mcp",
"codex-protocol",
"codex-tools",
"codex-utils-absolute-path",
"codex-utils-path-uri",
"codex-utils-string",
"pretty_assertions",
"schemars 0.8.22",
"serde",
"serde_json",
"tokio",
"tracing",
"url",
]
[[package]]
@@ -4198,6 +4231,7 @@ dependencies = [
"codex-exec-server",
"codex-login",
"codex-utils-absolute-path",
"codex-utils-path-uri",
"serde",
"serde_json",
"tempfile",
@@ -4580,6 +4614,7 @@ dependencies = [
"codex-exec-server",
"codex-extension-api",
"codex-features",
"codex-home",
"codex-hooks",
"codex-login",
"codex-model-provider-info",
@@ -4587,6 +4622,7 @@ dependencies = [
"codex-protocol",
"codex-utils-absolute-path",
"codex-utils-cargo-bin",
"codex-utils-path-uri",
"ctor 0.6.3",
"futures",
"notify",
+3
View File
@@ -21,6 +21,7 @@ members = [
"install-context",
"codex-backend-openapi-models",
"code-mode",
"codex-home",
"cloud-config",
"cloud-tasks",
"cloud-tasks-client",
@@ -157,6 +158,7 @@ codex-cloud-config = { path = "cloud-config" }
codex-cloud-tasks-client = { path = "cloud-tasks-client" }
codex-cloud-tasks-mock-client = { path = "cloud-tasks-mock-client" }
codex-code-mode = { path = "code-mode" }
codex-home = { path = "codex-home" }
codex-config = { path = "config" }
codex-connectors = { path = "connectors" }
codex-context-fragments = { path = "context-fragments" }
@@ -237,6 +239,7 @@ codex-utils-json-to-toml = { path = "utils/json-to-toml" }
codex-utils-oss = { path = "utils/oss" }
codex-utils-output-truncation = { path = "utils/output-truncation" }
codex-utils-path = { path = "utils/path-utils" }
codex-utils-path-uri = { path = "utils/path-uri" }
codex-utils-plugins = { path = "utils/plugins" }
codex-utils-pty = { path = "utils/pty" }
codex-utils-rustls-provider = { path = "utils/rustls-provider" }
+147 -17
View File
@@ -43,6 +43,7 @@ use crate::facts::AppInvocation;
use crate::facts::AppMentionedInput;
use crate::facts::AppUsedInput;
use crate::facts::CodexCompactionEvent;
use crate::facts::CodexErrKind;
use crate::facts::CompactionImplementation;
use crate::facts::CompactionPhase;
use crate::facts::CompactionReason;
@@ -778,6 +779,33 @@ fn sample_initialize_fact(connection_id: u64) -> AnalyticsFact {
}
}
async fn ingest_complete_child_turn(
reducer: &mut AnalyticsReducer,
events: &mut Vec<TrackEventRequest>,
thread_id: &str,
turn_id: &str,
) {
for fact in [
AnalyticsFact::Custom(CustomAnalyticsFact::TurnResolvedConfig(Box::new(
sample_turn_resolved_config(thread_id, turn_id),
))),
AnalyticsFact::Custom(CustomAnalyticsFact::TurnProfile(Box::new(
TurnProfileFact {
turn_id: turn_id.to_string(),
profile: sample_turn_profile(),
},
))),
AnalyticsFact::Notification(Box::new(sample_turn_completed_notification(
thread_id,
turn_id,
AppServerTurnStatus::Completed,
/*codex_error_info*/ None,
))),
] {
reducer.ingest(fact, events).await;
}
}
fn sample_command_execution_item(
status: CommandExecutionStatus,
exit_code: Option<i32>,
@@ -1251,11 +1279,13 @@ fn compaction_event_serializes_expected_shape() {
phase: CompactionPhase::MidTurn,
strategy: CompactionStrategy::Memento,
status: CompactionStatus::Completed,
error: None,
codex_error_kind: None,
codex_error_http_status_code: None,
active_context_tokens_before: 120_000,
active_context_tokens_after: 18_000,
retained_image_count: None,
compaction_summary_tokens: None,
cached_input_tokens: None,
started_at: 100,
completed_at: 106,
duration_ms: Some(6543),
@@ -1301,11 +1331,13 @@ fn compaction_event_serializes_expected_shape() {
"phase": "mid_turn",
"strategy": "memento",
"status": "completed",
"error": null,
"codex_error_kind": null,
"codex_error_http_status_code": null,
"active_context_tokens_before": 120000,
"active_context_tokens_after": 18000,
"retained_image_count": null,
"compaction_summary_tokens": null,
"cached_input_tokens": null,
"started_at": 100,
"completed_at": 106,
"duration_ms": 6543
@@ -1823,11 +1855,13 @@ async fn compaction_event_ingests_custom_fact() {
phase: CompactionPhase::StandaloneTurn,
strategy: CompactionStrategy::Memento,
status: CompactionStatus::Failed,
error: Some("context limit exceeded".to_string()),
codex_error_kind: Some(CodexErrKind::ContextWindowExceeded),
codex_error_http_status_code: None,
active_context_tokens_before: 131_000,
active_context_tokens_after: 131_000,
retained_image_count: None,
compaction_summary_tokens: None,
cached_input_tokens: None,
started_at: 100,
completed_at: 101,
duration_ms: Some(1200),
@@ -1843,6 +1877,14 @@ async fn compaction_event_ingests_custom_fact() {
assert_eq!(payload[0]["event_params"]["session_id"], "session-thread-1");
assert_eq!(payload[0]["event_params"]["thread_id"], "thread-1");
assert_eq!(payload[0]["event_params"]["turn_id"], "turn-compact");
assert_eq!(
payload[0]["event_params"]["codex_error_kind"],
json!("context_window_exceeded")
);
assert_eq!(
payload[0]["event_params"]["codex_error_http_status_code"],
json!(null)
);
assert_eq!(
payload[0]["event_params"]["app_server_client"]["product_client_id"],
DEFAULT_ORIGINATOR
@@ -1937,6 +1979,7 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() {
decision: GuardianReviewDecision::Denied,
terminal_status: GuardianReviewTerminalStatus::TimedOut,
failure_reason: Some(GuardianReviewFailureReason::Timeout),
attempt_count: 1,
risk_level: None,
user_authorization: None,
outcome: None,
@@ -2008,6 +2051,7 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() {
);
assert_eq!(payload[0]["event_params"]["terminal_status"], "timed_out");
assert_eq!(payload[0]["event_params"]["failure_reason"], "timeout");
assert_eq!(payload[0]["event_params"]["attempt_count"], 1);
assert_eq!(payload[0]["event_params"]["review_timeout_ms"], 90_000);
}
@@ -2665,7 +2709,7 @@ async fn subagent_thread_started_publishes_without_initialize() {
}
#[tokio::test]
async fn subagent_thread_started_inherits_parent_connection_for_new_thread() {
async fn subagent_events_use_inherited_connection_unless_turn_connection_is_explicit() {
let mut reducer = AnalyticsReducer::default();
let mut events = Vec::new();
let parent_thread_id =
@@ -2747,11 +2791,13 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() {
phase: CompactionPhase::StandaloneTurn,
strategy: CompactionStrategy::Memento,
status: CompactionStatus::Completed,
error: None,
codex_error_kind: None,
codex_error_http_status_code: None,
active_context_tokens_before: 131_000,
active_context_tokens_after: 64_000,
retained_image_count: None,
compaction_summary_tokens: None,
cached_input_tokens: None,
started_at: 100,
completed_at: 101,
duration_ms: Some(1200),
@@ -2772,6 +2818,68 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() {
payload[0]["event_params"]["parent_thread_id"],
"44444444-4444-4444-4444-444444444444"
);
events.clear();
ingest_complete_child_turn(&mut reducer, &mut events, "thread-review", "turn-inherited").await;
let [TrackEventRequest::TurnEvent(event)] = events.as_slice() else {
panic!("expected one turn event");
};
let params = &event.event_params;
assert_eq!(params.session_id, "session-root");
assert_eq!(params.thread_source, Some(ThreadSource::Subagent));
assert_eq!(params.subagent_source.as_deref(), Some("thread_spawn"));
assert_eq!(
params.parent_thread_id.as_deref(),
Some("44444444-4444-4444-4444-444444444444")
);
assert_eq!(params.app_server_client.product_client_id, "parent-client");
assert_eq!(params.runtime.codex_rs_version, "0.1.0");
reducer
.ingest(
AnalyticsFact::Custom(CustomAnalyticsFact::TurnTokenUsage(Box::new(
sample_turn_token_usage_fact("thread-review", "turn-inherited"),
))),
&mut events,
)
.await;
assert_eq!(events.len(), 1);
events.clear();
reducer
.ingest(sample_initialize_fact(/*connection_id*/ 8), &mut events)
.await;
reducer
.ingest(
AnalyticsFact::ClientRequest {
connection_id: 8,
request_id: RequestId::Integer(3),
request: Box::new(sample_turn_start_request(
"thread-review",
/*request_id*/ 3,
)),
},
&mut events,
)
.await;
reducer
.ingest(
AnalyticsFact::ClientResponse {
connection_id: 8,
request_id: RequestId::Integer(3),
response: Box::new(sample_turn_start_response("turn-explicit")),
},
&mut events,
)
.await;
ingest_complete_child_turn(&mut reducer, &mut events, "thread-review", "turn-explicit").await;
let [TrackEventRequest::TurnEvent(event)] = events.as_slice() else {
panic!("expected one turn event");
};
assert_eq!(
event.event_params.app_server_client.product_client_id,
DEFAULT_ORIGINATOR
);
}
#[tokio::test]
@@ -3755,6 +3863,32 @@ async fn turn_event_counts_completed_tool_items() {
)
.await;
let mcp_tool_call_item = |status, duration_ms| ThreadItem::McpToolCall {
id: "mcp-1".to_string(),
server: "server".to_string(),
tool: "search".to_string(),
status,
arguments: json!({}),
mcp_app_resource_uri: None,
plugin_id: Some("sample@test".to_string()),
result: None,
error: None,
duration_ms,
};
reducer
.ingest(
AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted(
ItemStartedNotification {
thread_id: "thread-2".to_string(),
turn_id: "turn-2".to_string(),
started_at_ms: 998,
item: mcp_tool_call_item(McpToolCallStatus::InProgress, None),
},
))),
&mut out,
)
.await;
let completed_tool_items = vec![
sample_command_execution_item(CommandExecutionStatus::Completed, Some(0), Some(1)),
ThreadItem::FileChange {
@@ -3762,18 +3896,7 @@ async fn turn_event_counts_completed_tool_items() {
changes: Vec::new(),
status: PatchApplyStatus::Completed,
},
ThreadItem::McpToolCall {
id: "mcp-1".to_string(),
server: "server".to_string(),
tool: "search".to_string(),
status: McpToolCallStatus::Completed,
arguments: json!({}),
mcp_app_resource_uri: None,
plugin_id: None,
result: None,
error: None,
duration_ms: Some(2),
},
mcp_tool_call_item(McpToolCallStatus::Completed, Some(2)),
ThreadItem::DynamicToolCall {
id: "dynamic-1".to_string(),
namespace: None,
@@ -3831,6 +3954,13 @@ async fn turn_event_counts_completed_tool_items() {
.await;
}
let mcp_tool_call_event = out
.iter()
.find(|event| matches!(event, TrackEventRequest::McpToolCall(_)))
.expect("MCP tool call event should be emitted");
let payload = serde_json::to_value(mcp_tool_call_event).expect("serialize MCP tool call event");
assert_eq!(payload["event_params"]["plugin_id"], json!("sample@test"));
reducer
.ingest(
AnalyticsFact::Notification(Box::new(sample_turn_completed_notification(
+11 -2
View File
@@ -266,6 +266,7 @@ pub struct GuardianReviewEventParams {
pub decision: GuardianReviewDecision,
pub terminal_status: GuardianReviewTerminalStatus,
pub failure_reason: Option<GuardianReviewFailureReason>,
pub attempt_count: i64,
pub risk_level: Option<GuardianRiskLevel>,
pub user_authorization: Option<GuardianUserAuthorization>,
pub outcome: Option<GuardianAssessmentOutcome>,
@@ -338,6 +339,7 @@ impl GuardianReviewTrackContext {
decision: result.decision,
terminal_status: result.terminal_status,
failure_reason: result.failure_reason,
attempt_count: result.attempt_count,
risk_level: result.risk_level,
user_authorization: result.user_authorization,
outcome: result.outcome,
@@ -373,6 +375,7 @@ pub struct GuardianReviewAnalyticsResult {
pub decision: GuardianReviewDecision,
pub terminal_status: GuardianReviewTerminalStatus,
pub failure_reason: Option<GuardianReviewFailureReason>,
pub attempt_count: i64,
pub risk_level: Option<GuardianRiskLevel>,
pub user_authorization: Option<GuardianUserAuthorization>,
pub outcome: Option<GuardianAssessmentOutcome>,
@@ -392,6 +395,7 @@ impl GuardianReviewAnalyticsResult {
decision: GuardianReviewDecision::Denied,
terminal_status: GuardianReviewTerminalStatus::FailedClosed,
failure_reason: None,
attempt_count: 1,
risk_level: None,
user_authorization: None,
outcome: None,
@@ -629,6 +633,7 @@ pub(crate) struct CodexMcpToolCallEventParams {
pub(crate) mcp_server_name: String,
pub(crate) mcp_tool_name: String,
pub(crate) mcp_error_present: bool,
pub(crate) plugin_id: Option<String>,
}
#[derive(Serialize)]
@@ -758,11 +763,13 @@ pub(crate) struct CodexCompactionEventParams {
pub(crate) phase: CompactionPhase,
pub(crate) strategy: CompactionStrategy,
pub(crate) status: CompactionStatus,
pub(crate) error: Option<String>,
pub(crate) codex_error_kind: Option<CodexErrKind>,
pub(crate) codex_error_http_status_code: Option<u16>,
pub(crate) active_context_tokens_before: i64,
pub(crate) active_context_tokens_after: i64,
pub(crate) retained_image_count: Option<usize>,
pub(crate) compaction_summary_tokens: Option<i64>,
pub(crate) cached_input_tokens: Option<i64>,
pub(crate) started_at: u64,
pub(crate) completed_at: u64,
pub(crate) duration_ms: Option<u64>,
@@ -995,11 +1002,13 @@ pub(crate) fn codex_compaction_event_params(
phase: input.phase,
strategy: input.strategy,
status: input.status,
error: input.error,
codex_error_kind: input.codex_error_kind,
codex_error_http_status_code: input.codex_error_http_status_code,
active_context_tokens_before: input.active_context_tokens_before,
active_context_tokens_after: input.active_context_tokens_after,
retained_image_count: input.retained_image_count,
compaction_summary_tokens: input.compaction_summary_tokens,
cached_input_tokens: input.cached_input_tokens,
started_at: input.started_at,
completed_at: input.completed_at,
duration_ms: input.duration_ms,
+5 -2
View File
@@ -137,7 +137,7 @@ impl TurnCodexErrorFact {
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum CodexErrKind {
pub enum CodexErrKind {
TurnAborted,
Stream,
ContextWindowExceeded,
@@ -364,6 +364,7 @@ pub enum CompactionReason {
UserRequested,
ContextLimit,
ModelDownshift,
CompHashChanged,
}
#[derive(Clone, Copy, Debug, Serialize)]
@@ -407,11 +408,13 @@ pub struct CodexCompactionEvent {
pub phase: CompactionPhase,
pub strategy: CompactionStrategy,
pub status: CompactionStatus,
pub error: Option<String>,
pub codex_error_kind: Option<CodexErrKind>,
pub codex_error_http_status_code: Option<u16>,
pub active_context_tokens_before: i64,
pub active_context_tokens_after: i64,
pub retained_image_count: Option<usize>,
pub compaction_summary_tokens: Option<i64>,
pub cached_input_tokens: Option<i64>,
pub started_at: u64,
pub completed_at: u64,
pub duration_ms: Option<u64>,
+1
View File
@@ -24,6 +24,7 @@ pub use facts::AcceptedLineFingerprint;
pub use facts::AnalyticsJsonRpcError;
pub use facts::AppInvocation;
pub use facts::CodexCompactionEvent;
pub use facts::CodexErrKind;
pub use facts::CodexGoalEvent;
pub use facts::CodexTurnSteerEvent;
pub use facts::CompactionImplementation;
+11 -3
View File
@@ -1497,17 +1497,23 @@ impl AnalyticsReducer {
let Some(thread_id) = turn_state.thread_id.as_ref() else {
return;
};
let Some(connection_id) = turn_state.connection_id else {
let drop_site = AnalyticsDropSite::turn(thread_id, turn_id);
let connection_id = turn_state.connection_id.or_else(|| {
self.threads
.get(drop_site.thread_id)
.and_then(|thread| thread.connection_id)
});
let Some(connection_id) = connection_id else {
warn_missing_analytics_context(&drop_site, MissingAnalyticsContext::ThreadConnection);
return;
};
let Some(connection_state) = self.connections.get(&connection_id) else {
warn_missing_analytics_context(
&AnalyticsDropSite::turn(thread_id, turn_id),
&drop_site,
MissingAnalyticsContext::Connection { connection_id },
);
return;
};
let drop_site = AnalyticsDropSite::turn(thread_id, turn_id);
let Some(thread_metadata) = self
.threads
.get(drop_site.thread_id)
@@ -1745,6 +1751,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
status,
error,
duration_ms,
plugin_id,
..
} => {
let (terminal_status, failure_kind) = mcp_tool_call_outcome(status)?;
@@ -1774,6 +1781,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
mcp_server_name: server.clone(),
mcp_tool_name: tool.clone(),
mcp_error_present: error.is_some(),
plugin_id: plugin_id.clone(),
},
},
))
+30 -17
View File
@@ -22,6 +22,7 @@ use std::fmt;
use std::io::Error as IoError;
use std::io::ErrorKind;
use std::io::Result as IoResult;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
@@ -48,7 +49,11 @@ use codex_config::LoaderOverrides;
use codex_config::NoopThreadConfigLoader;
use codex_config::RemoteThreadConfigLoader;
use codex_config::ThreadConfigLoader;
use codex_config::config_toml::ConfigToml;
use codex_core::config::Config;
pub use codex_core::otel_init::build_provider as build_otel_provider;
use codex_core::personality_migration::PersonalityMigrationStatus;
use codex_core::personality_migration::maybe_migrate_personality;
pub use codex_exec_server::EnvironmentManager;
pub use codex_exec_server::ExecServerRuntimePaths;
use codex_feedback::CodexFeedback;
@@ -72,7 +77,6 @@ pub use crate::remote::RemoteAppServerEndpoint;
pub mod legacy_core {
pub use codex_core::check_execpolicy_for_warnings;
pub use codex_core::format_exec_policy_error_with_source;
pub use codex_core::grant_read_root_non_elevated;
pub mod config {
pub use codex_core::config::*;
@@ -81,26 +85,27 @@ pub mod legacy_core {
pub use codex_core::config::edit::*;
}
}
pub mod otel_init {
pub use codex_core::otel_init::*;
}
pub mod personality_migration {
pub use codex_core::personality_migration::*;
}
pub mod test_support {
pub use codex_core::test_support::*;
}
pub mod windows_sandbox {
pub use codex_core::windows_sandbox::*;
}
}
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
/// Runs the embedded app-server personality migration.
///
/// Returns `true` when the migration changed config and the caller should reload it.
pub async fn migrate_personality_if_needed(
codex_home: &Path,
config_toml: &ConfigToml,
state_db: Option<StateDbHandle>,
) -> IoResult<bool> {
let status = maybe_migrate_personality(codex_home, config_toml, state_db).await?;
match status {
PersonalityMigrationStatus::Applied => Ok(true),
PersonalityMigrationStatus::SkippedMarker
| PersonalityMigrationStatus::SkippedExplicitPersonality
| PersonalityMigrationStatus::SkippedNoSessions => Ok(false),
}
}
/// Raw app-server request result for typed in-process requests.
///
/// Even on the in-process path, successful responses still travel back through
@@ -158,6 +163,7 @@ pub(crate) fn server_notification_requires_delivery(notification: &ServerNotific
ServerNotification::TurnCompleted(_)
| ServerNotification::ThreadSettingsUpdated(_)
| ServerNotification::ItemCompleted(_)
| ServerNotification::ExternalAgentConfigImportCompleted(_)
| ServerNotification::AgentMessageDelta(_)
| ServerNotification::PlanDelta(_)
| ServerNotification::ReasoningSummaryTextDelta(_)
@@ -2139,6 +2145,13 @@ mod tests {
)
)
));
assert!(event_requires_delivery(
&InProcessServerEvent::ServerNotification(
codex_app_server_protocol::ServerNotification::ExternalAgentConfigImportCompleted(
codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification {},
)
)
));
assert!(!event_requires_delivery(&InProcessServerEvent::Lagged {
skipped: 1
}));
@@ -3203,6 +3203,17 @@
],
"type": "object"
},
"ThreadDeleteParams": {
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"type": "object"
},
"ThreadForkParams": {
"description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using a non-empty path, the thread_id param will be ignored. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.",
"properties": {
@@ -4514,6 +4525,30 @@
"title": "Thread/archiveRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/delete"
],
"title": "Thread/deleteRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadDeleteParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/deleteRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -532,6 +532,13 @@
"personalAccessToken"
],
"type": "string"
},
{
"description": "Amazon Bedrock bearer token managed by Codex.",
"enum": [
"bedrockApiKey"
],
"type": "string"
}
]
},
@@ -3523,6 +3530,17 @@
],
"type": "object"
},
"ThreadDeletedNotification": {
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"type": "object"
},
"ThreadGoal": {
"properties": {
"createdAt": {
@@ -5447,6 +5465,26 @@
"title": "Thread/archivedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"thread/deleted"
],
"title": "Thread/deletedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadDeletedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Thread/deletedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -324,6 +324,30 @@
"title": "Thread/archiveRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"thread/delete"
],
"title": "Thread/deleteRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ThreadDeleteParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/deleteRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -4061,6 +4085,26 @@
"title": "Thread/archivedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"thread/deleted"
],
"title": "Thread/deletedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ThreadDeletedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Thread/deletedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -6372,6 +6416,12 @@
"AppSummary": {
"description": "EXPERIMENTAL - app metadata summary for plugin responses.",
"properties": {
"category": {
"type": [
"string",
"null"
]
},
"description": {
"type": [
"string",
@@ -6389,15 +6439,11 @@
},
"name": {
"type": "string"
},
"needsAuth": {
"type": "boolean"
}
},
"required": [
"id",
"name",
"needsAuth"
"name"
],
"type": "object"
},
@@ -6409,6 +6455,12 @@
"null"
]
},
"category": {
"type": [
"string",
"null"
]
},
"description": {
"type": [
"string",
@@ -6684,6 +6736,13 @@
"personalAccessToken"
],
"type": "string"
},
{
"description": "Amazon Bedrock bearer token managed by Codex.",
"enum": [
"bedrockApiKey"
],
"type": "string"
}
]
},
@@ -16053,6 +16112,37 @@
"title": "ThreadCompactStartResponse",
"type": "object"
},
"ThreadDeleteParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadDeleteParams",
"type": "object"
},
"ThreadDeleteResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ThreadDeleteResponse",
"type": "object"
},
"ThreadDeletedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadDeletedNotification",
"type": "object"
},
"ThreadForkParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using a non-empty path, the thread_id param will be ignored. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.",
@@ -694,6 +694,12 @@
"AppSummary": {
"description": "EXPERIMENTAL - app metadata summary for plugin responses.",
"properties": {
"category": {
"type": [
"string",
"null"
]
},
"description": {
"type": [
"string",
@@ -711,15 +717,11 @@
},
"name": {
"type": "string"
},
"needsAuth": {
"type": "boolean"
}
},
"required": [
"id",
"name",
"needsAuth"
"name"
],
"type": "object"
},
@@ -731,6 +733,12 @@
"null"
]
},
"category": {
"type": [
"string",
"null"
]
},
"description": {
"type": [
"string",
@@ -1006,6 +1014,13 @@
"personalAccessToken"
],
"type": "string"
},
{
"description": "Amazon Bedrock bearer token managed by Codex.",
"enum": [
"bedrockApiKey"
],
"type": "string"
}
]
},
@@ -1263,6 +1278,30 @@
"title": "Thread/archiveRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/delete"
],
"title": "Thread/deleteRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadDeleteParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/deleteRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -11726,6 +11765,26 @@
"title": "Thread/archivedNotification",
"type": "object"
},
{
"properties": {
"method": {
"enum": [
"thread/deleted"
],
"title": "Thread/deletedNotificationMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadDeletedNotification"
}
},
"required": [
"method",
"params"
],
"title": "Thread/deletedNotification",
"type": "object"
},
{
"properties": {
"method": {
@@ -13870,6 +13929,37 @@
"title": "ThreadCompactStartResponse",
"type": "object"
},
"ThreadDeleteParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadDeleteParams",
"type": "object"
},
"ThreadDeleteResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ThreadDeleteResponse",
"type": "object"
},
"ThreadDeletedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadDeletedNotification",
"type": "object"
},
"ThreadForkParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using a non-empty path, the thread_id param will be ignored. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.",
@@ -38,6 +38,13 @@
"personalAccessToken"
],
"type": "string"
},
{
"description": "Amazon Bedrock bearer token managed by Codex.",
"enum": [
"bedrockApiKey"
],
"type": "string"
}
]
},
@@ -4,6 +4,12 @@
"AppSummary": {
"description": "EXPERIMENTAL - app metadata summary for plugin responses.",
"properties": {
"category": {
"type": [
"string",
"null"
]
},
"description": {
"type": [
"string",
@@ -21,15 +27,11 @@
},
"name": {
"type": "string"
},
"needsAuth": {
"type": "boolean"
}
},
"required": [
"id",
"name",
"needsAuth"
"name"
],
"type": "object"
},
@@ -8,6 +8,12 @@
"AppSummary": {
"description": "EXPERIMENTAL - app metadata summary for plugin responses.",
"properties": {
"category": {
"type": [
"string",
"null"
]
},
"description": {
"type": [
"string",
@@ -25,15 +31,11 @@
},
"name": {
"type": "string"
},
"needsAuth": {
"type": "boolean"
}
},
"required": [
"id",
"name",
"needsAuth"
"name"
],
"type": "object"
},
@@ -45,6 +47,12 @@
"null"
]
},
"category": {
"type": [
"string",
"null"
]
},
"description": {
"type": [
"string",
@@ -0,0 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadDeleteParams",
"type": "object"
}
@@ -0,0 +1,5 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ThreadDeleteResponse",
"type": "object"
}
@@ -0,0 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"threadId": {
"type": "string"
}
},
"required": [
"threadId"
],
"title": "ThreadDeletedNotification",
"type": "object"
}
+1 -1
View File
@@ -5,4 +5,4 @@
/**
* Authentication mode for OpenAI-backed providers.
*/
export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "agentIdentity" | "personalAccessToken";
export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "agentIdentity" | "personalAccessToken" | "bedrockApiKey";
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,4 +5,4 @@
/**
* EXPERIMENTAL - app metadata summary for plugin responses.
*/
export type AppSummary = { id: string, name: string, description: string | null, installUrl: string | null, needsAuth: boolean, };
export type AppSummary = { id: string, name: string, description: string | null, installUrl: string | null, category: string | null, };
@@ -3,4 +3,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AppTemplateUnavailableReason } from "./AppTemplateUnavailableReason";
export type AppTemplateSummary = { templateId: string, name: string, description: string | null, canonicalConnectorId: string | null, logoUrl: string | null, logoUrlDark: string | null, materializedAppIds: Array<string>, reason: AppTemplateUnavailableReason | null, };
export type AppTemplateSummary = { templateId: string, name: string, description: string | null, category: string | null, canonicalConnectorId: string | null, logoUrl: string | null, logoUrlDark: string | null, materializedAppIds: Array<string>, reason: AppTemplateUnavailableReason | null, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadDeleteParams = { threadId: string, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadDeleteResponse = Record<string, never>;
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadDeletedNotification = { threadId: string, };
@@ -374,6 +374,9 @@ export type { ThreadArchivedNotification } from "./ThreadArchivedNotification";
export type { ThreadClosedNotification } from "./ThreadClosedNotification";
export type { ThreadCompactStartParams } from "./ThreadCompactStartParams";
export type { ThreadCompactStartResponse } from "./ThreadCompactStartResponse";
export type { ThreadDeleteParams } from "./ThreadDeleteParams";
export type { ThreadDeleteResponse } from "./ThreadDeleteResponse";
export type { ThreadDeletedNotification } from "./ThreadDeletedNotification";
export type { ThreadForkParams } from "./ThreadForkParams";
export type { ThreadForkResponse } from "./ThreadForkResponse";
export type { ThreadGoal } from "./ThreadGoal";
+5 -2
View File
@@ -39,8 +39,11 @@ use ts_rs::TS;
pub(crate) const GENERATED_TS_HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n";
const IGNORED_DEFINITIONS: &[&str] = &["Option<()>"];
const JSON_V1_ALLOWLIST: &[&str] = &["InitializeParams", "InitializeResponse"];
const EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES: &[&str] =
&["RemoteControlClient", "RemoteControlClientsListOrder"];
const EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES: &[&str] = &[
"RemoteControlClient",
"RemoteControlClientsListOrder",
"ThreadBackgroundTerminal",
];
const SPECIAL_DEFINITIONS: &[&str] = &[
"ClientNotification",
"ClientRequest",
@@ -41,6 +41,11 @@ pub enum AuthMode {
#[ts(rename = "personalAccessToken")]
#[strum(serialize = "personalAccessToken")]
PersonalAccessToken,
/// Amazon Bedrock bearer token managed by Codex.
#[serde(rename = "bedrockApiKey")]
#[ts(rename = "bedrockApiKey")]
#[strum(serialize = "bedrockApiKey")]
BedrockApiKey,
}
impl AuthMode {
@@ -48,7 +53,7 @@ impl AuthMode {
pub fn has_chatgpt_account(self) -> bool {
match self {
Self::Chatgpt | Self::ChatgptAuthTokens | Self::PersonalAccessToken => true,
Self::ApiKey | Self::AgentIdentity => false,
Self::ApiKey | Self::AgentIdentity | Self::BedrockApiKey => false,
}
}
}
@@ -480,6 +485,11 @@ client_request_definitions! {
serialization: thread_id(params.thread_id),
response: v2::ThreadArchiveResponse,
},
ThreadDelete => "thread/delete" {
params: v2::ThreadDeleteParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadDeleteResponse,
},
ThreadUnsubscribe => "thread/unsubscribe" {
params: v2::ThreadUnsubscribeParams,
serialization: thread_id(params.thread_id),
@@ -574,6 +584,18 @@ client_request_definitions! {
serialization: thread_id(params.thread_id),
response: v2::ThreadBackgroundTerminalsCleanResponse,
},
#[experimental("thread/backgroundTerminals/list")]
ThreadBackgroundTerminalsList => "thread/backgroundTerminals/list" {
params: v2::ThreadBackgroundTerminalsListParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadBackgroundTerminalsListResponse,
},
#[experimental("thread/backgroundTerminals/terminate")]
ThreadBackgroundTerminalsTerminate => "thread/backgroundTerminals/terminate" {
params: v2::ThreadBackgroundTerminalsTerminateParams,
serialization: thread_id(params.thread_id),
response: v2::ThreadBackgroundTerminalsTerminateResponse,
},
ThreadRollback => "thread/rollback" {
params: v2::ThreadRollbackParams,
serialization: thread_id(params.thread_id),
@@ -1522,6 +1544,7 @@ server_notification_definitions! {
ThreadStarted => "thread/started" (v2::ThreadStartedNotification),
ThreadStatusChanged => "thread/status/changed" (v2::ThreadStatusChangedNotification),
ThreadArchived => "thread/archived" (v2::ThreadArchivedNotification),
ThreadDeleted => "thread/deleted" (v2::ThreadDeletedNotification),
ThreadUnarchived => "thread/unarchived" (v2::ThreadUnarchivedNotification),
ThreadClosed => "thread/closed" (v2::ThreadClosedNotification),
SkillsChanged => "skills/changed" (v2::SkillsChangedNotification),
@@ -2936,6 +2959,54 @@ mod tests {
Ok(())
}
#[test]
fn serialize_thread_background_terminals_list() -> Result<()> {
let request = ClientRequest::ThreadBackgroundTerminalsList {
request_id: RequestId::Integer(8),
params: v2::ThreadBackgroundTerminalsListParams {
thread_id: "thr_123".to_string(),
cursor: None,
limit: None,
},
};
assert_eq!(
json!({
"method": "thread/backgroundTerminals/list",
"id": 8,
"params": {
"threadId": "thr_123",
"cursor": null,
"limit": null
}
}),
serde_json::to_value(&request)?,
);
Ok(())
}
#[test]
fn serialize_thread_background_terminals_terminate() -> Result<()> {
let request = ClientRequest::ThreadBackgroundTerminalsTerminate {
request_id: RequestId::Integer(8),
params: v2::ThreadBackgroundTerminalsTerminateParams {
thread_id: "thr_123".to_string(),
process_id: "42".to_string(),
},
};
assert_eq!(
json!({
"method": "thread/backgroundTerminals/terminate",
"id": 8,
"params": {
"threadId": "thr_123",
"processId": "42"
}
}),
serde_json::to_value(&request)?,
);
Ok(())
}
#[test]
fn serialize_thread_realtime_start() -> Result<()> {
let request = ClientRequest::ThreadRealtimeStart {
@@ -102,6 +102,33 @@ pub struct AppInfo {
pub plugin_display_names: Vec<String>,
}
impl AppInfo {
pub fn category(&self) -> Option<String> {
self.branding
.as_ref()
.and_then(|branding| non_empty_category(branding.category.as_deref()))
.or_else(|| {
self.app_metadata
.as_ref()
.and_then(|metadata| metadata.categories.as_ref())
.and_then(|categories| {
categories
.iter()
.find_map(|category| non_empty_category(Some(category.as_str())))
})
})
}
}
fn non_empty_category(category: Option<&str>) -> Option<String> {
let category = category?.trim();
if category.is_empty() {
None
} else {
Some(category.to_string())
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -111,17 +138,18 @@ pub struct AppSummary {
pub name: String,
pub description: Option<String>,
pub install_url: Option<String>,
pub needs_auth: bool,
pub category: Option<String>,
}
impl From<AppInfo> for AppSummary {
fn from(value: AppInfo) -> Self {
let category = value.category();
Self {
id: value.id,
name: value.name,
description: value.description,
install_url: value.install_url,
needs_auth: false,
category,
}
}
}
@@ -661,6 +661,7 @@ pub struct AppTemplateSummary {
pub template_id: String,
pub name: String,
pub description: Option<String>,
pub category: Option<String>,
pub canonical_connector_id: Option<String>,
pub logo_url: Option<String>,
pub logo_url_dark: Option<String>,
@@ -594,6 +594,18 @@ pub struct ThreadArchiveParams {
#[ts(export_to = "v2/")]
pub struct ThreadArchiveResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadDeleteParams {
pub thread_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadDeleteResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -935,6 +947,57 @@ pub struct ThreadBackgroundTerminalsCleanParams {
#[ts(export_to = "v2/")]
pub struct ThreadBackgroundTerminalsCleanResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadBackgroundTerminalsListParams {
pub thread_id: String,
/// Opaque pagination cursor returned by a previous call.
#[ts(optional = nullable)]
pub cursor: Option<String>,
/// Optional page size.
#[ts(optional = nullable)]
pub limit: Option<u32>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadBackgroundTerminal {
pub item_id: String,
pub process_id: String,
pub command: String,
pub cwd: AbsolutePathBuf,
pub os_pid: Option<u32>,
pub cpu_percent: Option<f64>,
pub rss_kb: Option<u64>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadBackgroundTerminalsListResponse {
pub data: Vec<ThreadBackgroundTerminal>,
/// Opaque cursor to pass to the next call to continue after the last item.
/// If None, there are no more items to return.
pub next_cursor: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadBackgroundTerminalsTerminateParams {
pub thread_id: String,
pub process_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadBackgroundTerminalsTerminateResponse {
pub terminated: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -1330,6 +1393,13 @@ pub struct ThreadArchivedNotification {
pub thread_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadDeletedNotification {
pub thread_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -68,7 +68,13 @@ pub struct TurnStartParams {
#[ts(optional = nullable)]
pub client_user_message_id: Option<String>,
pub input: Vec<UserInput>,
/// Optional turn-scoped Responses API client metadata.
/// Optional metadata to enrich Codex's ResponsesAPI turn metadata.
///
/// Entries are flattened into the JSON string sent as
/// `client_metadata["x-codex-turn-metadata"]` on ResponsesAPI HTTP and websocket requests.
///
/// They are not sent as top-level ResponsesAPI `client_metadata` keys, and reserved keys
/// such as `session_id`, `thread_id`, `turn_id`, and `window_id` cannot be overridden.
#[experimental("turn/start.responsesapiClientMetadata")]
#[ts(optional = nullable)]
pub responsesapi_client_metadata: Option<HashMap<String, String>>,
@@ -161,7 +167,13 @@ pub struct TurnSteerParams {
#[ts(optional = nullable)]
pub client_user_message_id: Option<String>,
pub input: Vec<UserInput>,
/// Optional turn-scoped Responses API client metadata.
/// Optional metadata to enrich Codex's ResponsesAPI turn metadata.
///
/// Entries are flattened into the JSON string sent as
/// `client_metadata["x-codex-turn-metadata"]` on ResponsesAPI HTTP and websocket requests.
///
/// They are not sent as top-level ResponsesAPI `client_metadata` keys, and reserved keys
/// such as `session_id`, `thread_id`, `turn_id`, and `window_id` cannot be overridden.
#[experimental("turn/steer.responsesapiClientMetadata")]
#[ts(optional = nullable)]
pub responsesapi_client_metadata: Option<HashMap<String, String>>,
@@ -116,6 +116,7 @@ fn remote_control_auth_dot_json(account_id: Option<&str>) -> AuthDotJson {
last_refresh: Some(chrono::Utc::now()),
agent_identity: None,
personal_access_token: None,
bedrock_api_key: None,
}
}
@@ -1842,6 +1842,7 @@ mod tests {
last_refresh: Some(Utc::now()),
agent_identity: None,
personal_access_token: None,
bedrock_api_key: None,
}
}
+2
View File
@@ -36,6 +36,7 @@ codex-cloud-config = { workspace = true }
codex-config = { workspace = true }
codex-core = { workspace = true }
codex-core-plugins = { workspace = true }
codex-home = { workspace = true }
codex-exec-server = { workspace = true }
codex-extension-api = { workspace = true }
codex-external-agent-migration = { workspace = true }
@@ -76,6 +77,7 @@ codex-thread-store = { workspace = true }
codex-tools = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-json-to-toml = { workspace = true }
codex-utils-path-uri = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true, features = ["derive"] }
futures = { workspace = true }
+40 -1
View File
@@ -151,12 +151,15 @@ Example with notification opt-out:
- `thread/settings/updated` — experimental notification emitted to subscribed clients when a loaded thread’s effective next-turn settings change; includes `threadId` and the full `threadSettings`.
- `thread/status/changed` — notification emitted when a loaded thread’s status changes (`threadId` + new `status`).
- `thread/archive` — move a thread’s rollout file into the archived directory and attempt to move any spawned descendant thread rollout files; returns `{}` on success and emits `thread/archived` for each archived thread.
- `thread/delete` — hard-delete an active or archived thread and any spawned descendant threads; returns `{}` on success and emits `thread/deleted` for each deleted thread.
- `thread/unsubscribe` — unsubscribe this connection from thread turn/item events. If this was the last subscriber, the server keeps the thread loaded and unloads it only after it has had no subscribers and no thread activity for 30 minutes, then emits `thread/closed`.
- `thread/name/set` — set or update a thread’s user-facing name for either a loaded thread or a persisted rollout; returns `{}` on success and emits `thread/name/updated` to initialized, opted-in clients. Thread names are not required to be unique; name lookups resolve to the most recently updated thread.
- `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success and emits `thread/unarchived`.
- `thread/compact/start` — trigger conversation history compaction for a thread; returns `{}` immediately while progress streams through standard turn/item notifications.
- `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream.
- `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted.
- `thread/backgroundTerminals/list` — list running background terminals for a loaded thread (experimental; requires `capabilities.experimentalApi`); returns `data` with the running terminal ids.
- `thread/backgroundTerminals/terminate` — terminate one running background terminal by app-server `processId` (experimental; requires `capabilities.experimentalApi`); returns whether a process was terminated.
- `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success.
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode".
- `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success.
@@ -203,7 +206,7 @@ Example with notification opt-out:
- `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors.
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**).
- `plugin/installed` — list installed plugin rows plus any explicitly requested local install-suggestion plugin names, without fetching the broader remote catalog. Mention surfaces can use this narrower view when they need plugin mention payloads rather than plugin-page discovery data (**under development; do not call from production clients yet**).
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**).
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Use `plugin/install`'s `appsNeedingAuth` to drive post-install authentication and `app/list`'s `isAccessible` to determine current connector accessibility (**under development; do not call from production clients yet**).
- `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle.
- `skills/changed` — notification emitted when watched local skill files change.
- `app/list` — list available apps.
@@ -610,6 +613,16 @@ Use `thread/archive` to move the persisted rollout (stored as a JSONL file on di
An archived thread will not appear in `thread/list` unless `archived` is set to `true`.
### Example: Delete a thread
Use `thread/delete` to hard-delete a thread and its spawned descendant threads. Existing rollout files and associated metadata must be removed before the request succeeds; missing rollout files are treated as already deleted.
```json
{ "method": "thread/delete", "id": 23, "params": { "threadId": "thr_b" } }
{ "id": 23, "result": {} }
{ "method": "thread/deleted", "params": { "threadId": "thr_b" } }
```
### Example: Unarchive a thread
Use `thread/unarchive` to move an archived rollout back into the sessions directory.
@@ -872,6 +885,32 @@ Use `thread/backgroundTerminals/clean` to terminate all running background termi
{ "id": 35, "result": {} }
```
### Example: List and terminate background terminals
Use `thread/backgroundTerminals/list` to inspect running background terminals associated with a loaded thread. The `backgroundTerminals` segment intentionally follows the existing `thread/backgroundTerminals/clean` method. The returned `processId` is the app-server process id; host OS metadata is nullable. The request accepts the standard `cursor` and `limit` pagination fields. When `nextCursor` is non-null, pass it as `cursor` to fetch the next page.
```json
{ "method": "thread/backgroundTerminals/list", "id": 36, "params": { "threadId": "thr_123" } }
{ "id": 36, "result": { "data": [
{
"itemId": "item_456",
"processId": "42",
"command": "python3 -m http.server",
"cwd": "/workspace",
"osPid": null,
"cpuPercent": null,
"rssKb": null
}
], "nextCursor": null } }
```
Use `thread/backgroundTerminals/terminate` to terminate one running background terminal by that `processId`.
```json
{ "method": "thread/backgroundTerminals/terminate", "id": 37, "params": { "threadId": "thr_123", "processId": "42" } }
{ "id": 37, "result": { "terminated": true } }
```
### Example: Steer an active turn
Use `turn/steer` to append additional user input to the currently active regular turn. This does
@@ -1147,7 +1147,7 @@ fn configured_marketplace_plugins(
) -> io::Result<BTreeMap<String, HashSet<String>>> {
let plugins_input = config.plugins_config_input();
let marketplaces = plugins_manager
.list_marketplaces_for_config(&plugins_input, &[])
.list_marketplaces_for_config(&plugins_input, &[], /*include_openai_curated*/ true)
.map_err(|err| {
invalid_data_error(format!("failed to list configured marketplaces: {err}"))
})?;
+19 -2
View File
@@ -21,11 +21,15 @@ use codex_protocol::error::CodexErr;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_rollout::state_db::StateDbHandle;
use codex_thread_store::ThreadStore;
use crate::outgoing_message::OutgoingMessageSender;
use crate::thread_state::ThreadListenerCommand;
use crate::thread_state::ThreadStateManager;
// TODO(jif): Enable once /ps/mcp serves complete hosted skill packages.
const ORCHESTRATOR_SKILLS_ENABLED: bool = false;
pub(crate) struct ThreadExtensionDependencies {
pub(crate) event_sink: Arc<dyn ExtensionEventSink>,
pub(crate) auth_manager: Arc<AuthManager>,
@@ -34,6 +38,8 @@ pub(crate) struct ThreadExtensionDependencies {
pub(crate) thread_manager: Weak<ThreadManager>,
pub(crate) goal_service: Arc<GoalService>,
pub(crate) executor_skill_provider: Arc<dyn codex_skills_extension::SkillProvider>,
/// Process-scoped persistence backend for extensions that need stored thread history.
pub(crate) thread_store: Arc<dyn ThreadStore>,
}
pub(crate) fn thread_extensions<S>(
@@ -51,6 +57,7 @@ where
thread_manager,
goal_service,
executor_skill_provider,
thread_store: _thread_store,
} = dependencies;
let mut builder = ExtensionRegistryBuilder::<Config>::with_event_sink(event_sink);
if let Some(state_db) = state_db {
@@ -69,10 +76,20 @@ where
codex_mcp_extension::install(&mut builder);
codex_web_search_extension::install(&mut builder, auth_manager.clone());
codex_image_generation_extension::install(&mut builder, auth_manager);
let mut skill_providers = codex_skills_extension::SkillProviders::new()
.with_executor_provider(executor_skill_provider);
if ORCHESTRATOR_SKILLS_ENABLED {
skill_providers = skill_providers.with_orchestrator_provider(Arc::new(
codex_skills_extension::OrchestratorSkillProvider::new(),
));
}
codex_skills_extension::install_with_providers(
&mut builder,
codex_skills_extension::SkillProviders::new()
.with_executor_provider(executor_skill_provider),
skill_providers,
|config: &Config| codex_skills_extension::SkillsExtensionConfig {
include_instructions: config.include_skill_instructions,
bundled_skills_enabled: config.bundled_skills_enabled(),
},
);
Arc::new(builder.build())
}
+9 -1
View File
@@ -104,7 +104,9 @@ type PendingClientRequestResponse = std::result::Result<Result, JSONRPCErrorErro
fn server_notification_requires_delivery(notification: &ServerNotification) -> bool {
matches!(
notification,
ServerNotification::TurnCompleted(_) | ServerNotification::ThreadSettingsUpdated(_)
ServerNotification::TurnCompleted(_)
| ServerNotification::ThreadSettingsUpdated(_)
| ServerNotification::ExternalAgentConfigImportCompleted(_)
)
}
@@ -729,6 +731,7 @@ mod tests {
use super::*;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ConfigRequirementsReadResponse;
use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification;
use codex_app_server_protocol::SessionSource as ApiSessionSource;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
@@ -893,5 +896,10 @@ mod tests {
},
})
));
assert!(server_notification_requires_delivery(
&ServerNotification::ExternalAgentConfigImportCompleted(
ExternalAgentConfigImportCompletedNotification {},
)
));
}
}
+129 -2
View File
@@ -1,3 +1,4 @@
#![recursion_limit = "256"]
#![deny(clippy::print_stdout, clippy::print_stderr)]
use codex_arg0::Arg0DispatchPaths;
@@ -74,6 +75,8 @@ use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::registry::Registry;
use tracing_subscriber::util::SubscriberInitExt;
const SQLITE_RECOVERY_CONFIG_WARNING_SUMMARY: &str = "Codex rebuilt its local database.";
mod analytics_utils;
mod app_server_tracing;
mod attestation;
@@ -533,8 +536,8 @@ pub async fn run_main_with_transport_options(
}
_ => None,
};
let state_db = match rollout_state_db::try_init(&config).await {
Ok(state_db) => Some(state_db),
let state_db_init = match init_sqlite_state_db_with_fresh_start_on_corruption(&config).await {
Ok(state_db_init) => state_db_init,
Err(err) => {
return Err(std::io::Error::other(format!(
"failed to initialize sqlite state runtime under {}: {err}",
@@ -542,6 +545,15 @@ pub async fn run_main_with_transport_options(
)));
}
};
let state_db = state_db_init.state_db;
if let Some(recovery_notice) = state_db_init.recovery_notice {
config_warnings.push(ConfigWarningNotification {
summary: SQLITE_RECOVERY_CONFIG_WARNING_SUMMARY.to_string(),
details: Some(recovery_notice.details),
path: None,
range: None,
});
}
if should_run_personality_migration {
let effective_toml = config.config_layer_stack.effective_config();
@@ -1099,6 +1111,121 @@ pub async fn run_main_with_transport_options(
Ok(())
}
struct SqliteRecoveryNotice {
details: String,
}
struct RecoveredSqliteDatabase {
database_path: String,
backup_folder: String,
}
struct StateDbInitResult {
state_db: Option<rollout_state_db::StateDbHandle>,
recovery_notice: Option<SqliteRecoveryNotice>,
}
async fn init_sqlite_state_db_with_fresh_start_on_corruption(
config: &Config,
) -> anyhow::Result<StateDbInitResult> {
let mut attempted_backups = HashSet::new();
let mut recovered_databases = Vec::new();
loop {
let err = match rollout_state_db::try_init(config).await {
Ok(state_db) => {
let recovery_notice = sqlite_recovery_notice(&recovered_databases);
if recovery_notice.is_some() {
emit_state_db_backup_warning(SQLITE_RECOVERY_CONFIG_WARNING_SUMMARY);
for recovered_database in &recovered_databases {
emit_state_db_backup_warning(&format!(
"Database path: {}",
recovered_database.database_path
));
emit_state_db_backup_warning(&format!(
"Backup folder: {}",
recovered_database.backup_folder
));
}
}
return Ok(StateDbInitResult {
state_db: Some(state_db),
recovery_notice,
});
}
Err(err) => err,
};
if !codex_state::is_sqlite_corruption_error(&err) {
return Err(err);
}
let database_path = codex_state::runtime_db_path_for_corruption_error(&err)
.unwrap_or_else(|| codex_state::state_db_path(config.sqlite_home.as_path()));
if !attempted_backups.insert(database_path.clone()) {
return Err(anyhow::anyhow!(
"failed to initialize sqlite state runtime after moving damaged database file into a backup folder: {err}"
));
}
let original_error = err.to_string();
emit_state_db_backup_warning(&format!(
"Codex local database at {} appears damaged. Moving it into a backup folder so the app server can rebuild it from saved data.",
database_path.display()
));
let backups = codex_state::backup_runtime_db_for_fresh_start(database_path.as_path())
.await
.map_err(|backup_err| {
anyhow::anyhow!(
"failed to move damaged sqlite state database files into a backup folder: {backup_err}; original error: {original_error}"
)
})?;
for backup in &backups {
emit_state_db_backup_warning(&format!(
"Moved damaged Codex local database file {} to {}",
backup.original_path.display(),
backup.backup_path.display()
));
}
if let Some(first_backup) = backups.first()
&& let Some(backup_folder) = first_backup.backup_path.parent()
{
recovered_databases.push(RecoveredSqliteDatabase {
database_path: first_backup.original_path.display().to_string(),
backup_folder: backup_folder.display().to_string(),
});
}
}
}
fn sqlite_recovery_notice(
recovered_databases: &[RecoveredSqliteDatabase],
) -> Option<SqliteRecoveryNotice> {
if recovered_databases.is_empty() {
return None;
}
let details = recovered_databases
.iter()
.map(|recovered_database| {
format!(
"Database path: {}\nBackup folder: {}",
recovered_database.database_path, recovered_database.backup_folder
)
})
.collect::<Vec<_>>()
.join("\n\n");
Some(SqliteRecoveryNotice { details })
}
fn emit_state_db_backup_warning(message: &str) {
warn!("{message}");
if !tracing::dispatcher::has_been_set() {
#[allow(clippy::print_stderr)]
{
eprintln!("{message}");
}
}
}
fn analytics_rpc_transport(transport: &AppServerTransport) -> AppServerRpcTransport {
match transport {
AppServerTransport::Stdio => AppServerRpcTransport::Stdio,
+5
View File
@@ -113,6 +113,7 @@ mod tests {
use codex_core::thread_store_from_config;
use codex_exec_server::EnvironmentManager;
use codex_extension_api::NoopExtensionEventSink;
use codex_home::CodexHomeUserInstructionsProvider;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_protocol::protocol::SessionSource;
@@ -202,8 +203,12 @@ mod tests {
thread_manager: thread_manager.clone(),
goal_service: Arc::new(codex_goal_extension::GoalService::new()),
executor_skill_provider: Arc::clone(&executor_skill_provider),
thread_store: Arc::clone(&thread_store),
},
),
Arc::new(CodexHomeUserInstructionsProvider::new(
good_config.codex_home.clone(),
)),
/*analytics_events_client*/ None,
Arc::clone(&thread_store),
Some(state_db.clone()),
+22 -1
View File
@@ -72,6 +72,7 @@ use codex_core::config::Config;
use codex_exec_server::EnvironmentManager;
use codex_feedback::CodexFeedback;
use codex_goal_extension::GoalService;
use codex_home::CodexHomeUserInstructionsProvider;
use codex_login::AuthManager;
use codex_login::auth::ExternalAuth;
use codex_login::auth::ExternalAuthRefreshContext;
@@ -336,8 +337,12 @@ impl MessageProcessor {
thread_manager: thread_manager.clone(),
goal_service: Arc::clone(&goal_service),
executor_skill_provider: Arc::clone(&executor_skill_provider),
thread_store: Arc::clone(&thread_store),
},
),
Arc::new(CodexHomeUserInstructionsProvider::new(
config.codex_home.clone(),
)),
Some(analytics_events_client.clone()),
Arc::clone(&thread_store),
state_db.clone(),
@@ -400,7 +405,7 @@ impl MessageProcessor {
Arc::clone(&thread_manager),
Arc::clone(&config),
feedback,
log_db,
log_db.clone(),
state_db.clone(),
);
let git_processor = GitRequestProcessor::new();
@@ -454,6 +459,7 @@ impl MessageProcessor {
Arc::clone(&thread_list_state_permit),
thread_goal_processor.clone(),
state_db,
log_db,
Arc::clone(&skills_watcher),
);
let turn_processor = TurnRequestProcessor::new(
@@ -1071,6 +1077,11 @@ impl MessageProcessor {
.thread_archive(request_id.clone(), params)
.await
}
ClientRequest::ThreadDelete { params, .. } => {
self.thread_processor
.thread_delete(request_id.clone(), params)
.await
}
ClientRequest::ThreadIncrementElicitation { params, .. } => {
self.thread_processor
.thread_increment_elicitation(params)
@@ -1126,6 +1137,16 @@ impl MessageProcessor {
.thread_background_terminals_clean(&request_id, params)
.await
}
ClientRequest::ThreadBackgroundTerminalsList { params, .. } => {
self.thread_processor
.thread_background_terminals_list(params)
.await
}
ClientRequest::ThreadBackgroundTerminalsTerminate { params, .. } => {
self.thread_processor
.thread_background_terminals_terminate(params)
.await
}
ClientRequest::ThreadRollback { params, .. } => {
self.thread_processor
.thread_rollback(&request_id, params)
@@ -171,13 +171,21 @@ 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::ThreadBackgroundTerminal;
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams;
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse;
use codex_app_server_protocol::ThreadBackgroundTerminalsListParams;
use codex_app_server_protocol::ThreadBackgroundTerminalsListResponse;
use codex_app_server_protocol::ThreadBackgroundTerminalsTerminateParams;
use codex_app_server_protocol::ThreadBackgroundTerminalsTerminateResponse;
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::ThreadDeleteParams;
use codex_app_server_protocol::ThreadDeleteResponse;
use codex_app_server_protocol::ThreadDeletedNotification;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadGoal;
@@ -414,6 +422,7 @@ use codex_rollout::state_db::reconcile_rollout;
use codex_state::ThreadMetadata;
use codex_state::log_db::LogDbLayer;
use codex_thread_store::ArchiveThreadParams as StoreArchiveThreadParams;
use codex_thread_store::DeleteThreadParams as StoreDeleteThreadParams;
use codex_thread_store::GitInfoPatch as StoreGitInfoPatch;
use codex_thread_store::ListThreadsParams as StoreListThreadsParams;
use codex_thread_store::LocalThreadStore;
@@ -534,6 +543,7 @@ fn resolve_runtime_workspace_roots(workspace_roots: Vec<AbsolutePathBuf>) -> Vec
mod config_errors;
mod request_errors;
mod thread_delete;
mod thread_goal_processor;
mod thread_lifecycle;
mod thread_resume_redaction;
@@ -158,6 +158,9 @@ impl AccountRequestProcessor {
pub(crate) fn clear_external_auth(&self) {
self.auth_manager.clear_external_auth();
self.thread_manager
.plugins_manager()
.set_auth_mode(self.auth_manager.get_api_auth_mode());
}
fn current_account_updated_notification(&self) -> AccountUpdatedNotification {
@@ -173,6 +176,10 @@ impl AccountRequestProcessor {
thread_manager: &Arc<ThreadManager>,
auth: Option<CodexAuth>,
) {
thread_manager
.plugins_manager()
.set_auth_mode(auth.as_ref().map(CodexAuth::api_auth_mode));
match config_manager
.load_latest_config(/*fallback_cwd*/ None)
.await
@@ -29,6 +29,7 @@ use codex_exec_server::CreateDirectoryOptions;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::RemoveOptions;
use codex_utils_path_uri::PathUri;
use std::io;
use std::sync::Arc;
@@ -64,9 +65,10 @@ impl FsRequestProcessor {
&self,
params: FsReadFileParams,
) -> Result<FsReadFileResponse, JSONRPCErrorError> {
let path = PathUri::from_abs_path(&params.path).map_err(map_fs_error)?;
let bytes = self
.file_system()?
.read_file(&params.path, /*sandbox*/ None)
.read_file(&path, /*sandbox*/ None)
.await
.map_err(map_fs_error)?;
Ok(FsReadFileResponse {
@@ -83,8 +85,9 @@ impl FsRequestProcessor {
"fs/writeFile requires valid base64 dataBase64: {err}"
))
})?;
let path = PathUri::from_abs_path(&params.path).map_err(map_fs_error)?;
self.file_system()?
.write_file(&params.path, bytes, /*sandbox*/ None)
.write_file(&path, bytes, /*sandbox*/ None)
.await
.map_err(map_fs_error)?;
Ok(FsWriteFileResponse {})
@@ -94,9 +97,10 @@ impl FsRequestProcessor {
&self,
params: FsCreateDirectoryParams,
) -> Result<FsCreateDirectoryResponse, JSONRPCErrorError> {
let path = PathUri::from_abs_path(&params.path).map_err(map_fs_error)?;
self.file_system()?
.create_directory(
&params.path,
&path,
CreateDirectoryOptions {
recursive: params.recursive.unwrap_or(true),
},
@@ -111,9 +115,10 @@ impl FsRequestProcessor {
&self,
params: FsGetMetadataParams,
) -> Result<FsGetMetadataResponse, JSONRPCErrorError> {
let path = PathUri::from_abs_path(&params.path).map_err(map_fs_error)?;
let metadata = self
.file_system()?
.get_metadata(&params.path, /*sandbox*/ None)
.get_metadata(&path, /*sandbox*/ None)
.await
.map_err(map_fs_error)?;
Ok(FsGetMetadataResponse {
@@ -129,9 +134,10 @@ impl FsRequestProcessor {
&self,
params: FsReadDirectoryParams,
) -> Result<FsReadDirectoryResponse, JSONRPCErrorError> {
let path = PathUri::from_abs_path(&params.path).map_err(map_fs_error)?;
let entries = self
.file_system()?
.read_directory(&params.path, /*sandbox*/ None)
.read_directory(&path, /*sandbox*/ None)
.await
.map_err(map_fs_error)?;
Ok(FsReadDirectoryResponse {
@@ -150,9 +156,10 @@ impl FsRequestProcessor {
&self,
params: FsRemoveParams,
) -> Result<FsRemoveResponse, JSONRPCErrorError> {
let path = PathUri::from_abs_path(&params.path).map_err(map_fs_error)?;
self.file_system()?
.remove(
&params.path,
&path,
RemoveOptions {
recursive: params.recursive.unwrap_or(true),
force: params.force.unwrap_or(true),
@@ -168,10 +175,13 @@ impl FsRequestProcessor {
&self,
params: FsCopyParams,
) -> Result<FsCopyResponse, JSONRPCErrorError> {
let source_path = PathUri::from_abs_path(&params.source_path).map_err(map_fs_error)?;
let destination_path =
PathUri::from_abs_path(&params.destination_path).map_err(map_fs_error)?;
self.file_system()?
.copy(
&params.source_path,
&params.destination_path,
&source_path,
&destination_path,
CopyOptions {
recursive: params.recursive,
},
@@ -573,6 +573,7 @@ impl PluginRequestProcessor {
.list_marketplaces_for_config(
&config_for_marketplace_listing,
&roots_for_marketplace_listing,
/*include_openai_curated*/ true,
)?;
Ok::<
(
@@ -837,8 +838,11 @@ impl PluginRequestProcessor {
let config_for_marketplace_listing = plugins_input.clone();
let shared_plugin_ids_by_local_path = load_shared_plugin_ids_by_local_path(config)?;
match tokio::task::spawn_blocking(move || {
let outcome = plugins_manager
.list_marketplaces_for_config(&config_for_marketplace_listing, &roots)?;
let outcome = plugins_manager.list_marketplaces_for_config(
&config_for_marketplace_listing,
&roots,
/*include_openai_curated*/ true,
)?;
Ok::<
(
Vec<PluginMarketplaceEntry>,
@@ -1031,12 +1035,10 @@ impl PluginRequestProcessor {
}
None => None,
};
let environment_manager = self.thread_manager.environment_manager();
let app_summaries = load_plugin_app_summaries(
&config,
&outcome.plugin.apps,
Arc::clone(&environment_manager),
self.thread_manager.mcp_manager(),
&outcome.plugin.app_category_by_id,
)
.await;
let visible_skills = outcome
@@ -1114,14 +1116,13 @@ impl PluginRequestProcessor {
.cloned()
.map(codex_plugin::AppConnectorId)
.collect::<Vec<_>>();
let environment_manager = self.thread_manager.environment_manager();
let app_summaries = load_plugin_app_summaries(
&config,
&plugin_apps,
Arc::clone(&environment_manager),
self.thread_manager.mcp_manager(),
)
.await;
let app_category_by_id = remote_detail
.app_manifest
.as_ref()
.map(plugin_app_category_by_id_from_value)
.unwrap_or_default();
let app_summaries =
load_plugin_app_summaries(&config, &plugin_apps, &app_category_by_id).await;
remote_plugin_detail_to_info(remote_detail, app_summaries)
}
};
@@ -1568,17 +1569,28 @@ impl PluginRequestProcessor {
.into_iter()
.map(codex_plugin::AppConnectorId)
.collect::<Vec<_>>();
let app_category_by_id = remote_detail
.app_manifest
.as_ref()
.map(plugin_app_category_by_id_from_value)
.unwrap_or_default();
let all_connectors = connectors::list_cached_all_connectors(&config)
.await
.unwrap_or_default();
connectors::connectors_for_plugin_apps(all_connectors, &plugin_apps)
.into_iter()
.map(|connector| AppSummary {
id: connector.id,
name: connector.name,
description: connector.description,
install_url: connector.install_url,
needs_auth: true,
.map(|connector| {
let category = app_category_by_id
.get(&connector.id)
.cloned()
.or_else(|| connector.category());
AppSummary {
category,
id: connector.id,
name: connector.name,
description: connector.description,
install_url: connector.install_url,
}
})
.collect()
}
@@ -1883,8 +1895,7 @@ impl PluginRequestProcessor {
async fn load_plugin_app_summaries(
config: &Config,
plugin_apps: &[codex_plugin::AppConnectorId],
environment_manager: Arc<EnvironmentManager>,
mcp_manager: Arc<McpManager>,
app_category_by_id: &HashMap<String, String>,
) -> Vec<AppSummary> {
if plugin_apps.is_empty() {
return Vec::new();
@@ -1903,51 +1914,31 @@ async fn load_plugin_app_summaries(
let plugin_connectors = connectors::connectors_for_plugin_apps(connectors, plugin_apps);
let accessible_connectors =
match connectors::list_accessible_connectors_from_mcp_tools_with_mcp_manager(
config,
/*force_refetch*/ false,
environment_manager,
mcp_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());
let category = app_category_by_id
.get(&connector.id)
.cloned()
.or_else(|| connector.category());
AppSummary {
id: connector.id,
name: connector.name,
description: connector.description,
install_url: connector.install_url,
needs_auth,
category,
}
})
.collect()
}
fn plugin_app_category_by_id_from_value(value: &serde_json::Value) -> HashMap<String, String> {
codex_core_plugins::loader::plugin_app_metadata_from_value(value)
.into_iter()
.filter_map(|app| app.category.map(|category| (app.id.0, category)))
.collect()
}
fn plugin_apps_needing_auth(
all_connectors: &[AppInfo],
accessible_connectors: &[AppInfo],
@@ -1974,12 +1965,15 @@ fn plugin_apps_needing_auth(
&& !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,
.map(|connector| {
let category = connector.category();
AppSummary {
category,
id: connector.id,
name: connector.name,
description: connector.description,
install_url: connector.install_url,
}
})
.collect()
}
@@ -2067,6 +2061,7 @@ fn remote_plugin_detail_to_info(
template_id: template.template_id,
name: template.name,
description: template.description,
category: template.category,
canonical_connector_id: template.canonical_connector_id,
logo_url: template.logo_url,
logo_url_dark: template.logo_url_dark,
@@ -0,0 +1,188 @@
//! `thread/delete` request handling.
use super::thread_processor::core_thread_write_error;
use super::thread_processor::unsupported_thread_store_operation;
use super::*;
impl ThreadRequestProcessor {
pub(crate) async fn thread_delete(
&self,
request_id: ConnectionRequestId,
params: ThreadDeleteParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
let mut deleted_thread_ids = Vec::new();
let result = {
let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?;
self.thread_delete_response(params, &mut deleted_thread_ids)
.await
};
match result {
Ok(response) => {
self.outgoing
.send_response(request_id.clone(), response)
.await;
self.send_thread_deleted_notifications(deleted_thread_ids)
.await;
Ok(None)
}
Err(error) => Err(error),
}
}
async fn thread_delete_response(
&self,
params: ThreadDeleteParams,
deleted_thread_ids: &mut Vec<String>,
) -> Result<ThreadDeleteResponse, JSONRPCErrorError> {
let thread_id = ThreadId::from_string(&params.thread_id)
.map_err(|err| invalid_request(format!("invalid thread id: {err}")))?;
let mut thread_ids = self.state_db_spawn_subtree_thread_ids(thread_id).await?;
let mut seen = thread_ids.iter().copied().collect::<HashSet<_>>();
match self
.thread_manager
.list_agent_subtree_thread_ids(thread_id)
.await
{
Ok(live_thread_ids) => {
for live_thread_id in live_thread_ids {
if seen.insert(live_thread_id) {
thread_ids.push(live_thread_id);
}
}
}
Err(err) => return Err(core_thread_write_error("delete thread", err)),
}
self.validate_root_thread_delete(thread_id, thread_ids.len() > 1)
.await?;
for thread_id_to_delete in thread_ids.iter().copied() {
self.prepare_thread_for_delete(thread_id_to_delete).await;
}
let mut delete_order: Vec<_> = thread_ids.iter().skip(1).rev().copied().collect();
delete_order.push(thread_id);
for thread_id_to_delete in delete_order.iter().copied() {
match self
.thread_store
.delete_thread(StoreDeleteThreadParams {
thread_id: thread_id_to_delete,
})
.await
{
Ok(()) => {}
Err(ThreadStoreError::ThreadNotFound { .. }) => {
warn!(
"thread {thread_id_to_delete} was already missing while deleting {thread_id}"
);
}
Err(err) => {
return Err(thread_store_delete_error(err));
}
}
}
if let Some(state_db) = self.state_db.as_ref() {
state_db
.delete_threads_strict(thread_ids.as_slice())
.await
.map_err(|err| {
internal_error(format!(
"failed to delete app-server state for {thread_id}: {err}"
))
})?;
}
deleted_thread_ids.extend(
delete_order
.into_iter()
.map(|thread_id| thread_id.to_string()),
);
Ok(ThreadDeleteResponse {})
}
async fn send_thread_deleted_notifications(&self, deleted_thread_ids: Vec<String>) {
for thread_id in deleted_thread_ids {
self.outgoing
.send_server_notification(ServerNotification::ThreadDeleted(
ThreadDeletedNotification { thread_id },
))
.await;
}
}
async fn validate_root_thread_delete(
&self,
thread_id: ThreadId,
has_descendants: bool,
) -> Result<(), JSONRPCErrorError> {
if let Ok(thread) = self.thread_manager.get_thread(thread_id).await {
if !thread.config_snapshot().await.ephemeral {
return Ok(());
}
return Err(invalid_request(format!(
"thread is not persisted and cannot be deleted: {thread_id}"
)));
}
match self
.thread_store
.read_thread(StoreReadThreadParams {
thread_id,
include_archived: true,
include_history: false,
})
.await
{
Ok(_) => Ok(()),
Err(ThreadStoreError::ThreadNotFound { .. }) => {
if has_descendants {
return Ok(());
}
let Some(state_db) = self.state_db.as_ref() else {
return Err(thread_store_delete_error(
ThreadStoreError::ThreadNotFound { thread_id },
));
};
if state_db
.get_thread(thread_id)
.await
.map_err(|err| {
internal_error(format!(
"failed to read app-server state for {thread_id}: {err}"
))
})?
.is_some()
{
Ok(())
} else {
Err(thread_store_delete_error(
ThreadStoreError::ThreadNotFound { thread_id },
))
}
}
Err(err) => Err(thread_store_delete_error(err)),
}
}
async fn prepare_thread_for_delete(&self, thread_id: ThreadId) {
self.prepare_thread_for_removal(thread_id, "delete").await;
if let Some(log_db) = self.log_db.as_ref() {
log_db.flush().await;
}
}
}
fn thread_store_delete_error(err: ThreadStoreError) -> JSONRPCErrorError {
match err {
ThreadStoreError::ThreadNotFound { thread_id } => {
invalid_request(format!("thread not found: {thread_id}"))
}
ThreadStoreError::InvalidRequest { message } => invalid_request(message),
ThreadStoreError::Unsupported { operation } => {
unsupported_thread_store_operation(operation)
}
err => internal_error(format!("failed to delete thread: {err}")),
}
}
@@ -325,6 +325,7 @@ pub(crate) struct ThreadRequestProcessor {
pub(super) thread_list_state_permit: Arc<Semaphore>,
pub(super) thread_goal_processor: ThreadGoalRequestProcessor,
pub(super) state_db: Option<StateDbHandle>,
pub(super) log_db: Option<LogDbLayer>,
pub(super) background_tasks: TaskTracker,
pub(super) skills_watcher: Arc<SkillsWatcher>,
}
@@ -356,6 +357,7 @@ impl ThreadRequestProcessor {
thread_list_state_permit: Arc<Semaphore>,
thread_goal_processor: ThreadGoalRequestProcessor,
state_db: Option<StateDbHandle>,
log_db: Option<LogDbLayer>,
skills_watcher: Arc<SkillsWatcher>,
) -> Self {
Self {
@@ -372,6 +374,7 @@ impl ThreadRequestProcessor {
thread_list_state_permit,
thread_goal_processor,
state_db,
log_db,
background_tasks: TaskTracker::new(),
skills_watcher,
}
@@ -569,6 +572,24 @@ impl ThreadRequestProcessor {
.map(|response| Some(response.into()))
}
pub(crate) async fn thread_background_terminals_list(
&self,
params: ThreadBackgroundTerminalsListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_background_terminals_list_inner(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn thread_background_terminals_terminate(
&self,
params: ThreadBackgroundTerminalsTerminateParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.thread_background_terminals_terminate_inner(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn thread_rollback(
&self,
request_id: &ConnectionRequestId,
@@ -678,7 +699,7 @@ impl ThreadRequestProcessor {
Ok((thread_id, thread))
}
async fn acquire_thread_list_state_permit(
pub(super) async fn acquire_thread_list_state_permit(
&self,
) -> Result<SemaphorePermit<'_>, JSONRPCErrorError> {
self.thread_list_state_permit
@@ -750,6 +771,10 @@ impl ThreadRequestProcessor {
}
async fn prepare_thread_for_archive(&self, thread_id: ThreadId) {
self.prepare_thread_for_removal(thread_id, "archive").await;
}
pub(super) async fn prepare_thread_for_removal(&self, thread_id: ThreadId, operation: &str) {
let removed_conversation = self.thread_manager.remove_thread(&thread_id).await;
if let Some(conversation) = removed_conversation {
info!("thread {thread_id} was active; shutting down");
@@ -757,11 +782,11 @@ impl ThreadRequestProcessor {
ThreadShutdownResult::Complete => {}
ThreadShutdownResult::SubmitFailed => {
error!(
"failed to submit Shutdown to thread {thread_id}; proceeding with archive"
"failed to submit Shutdown to thread {thread_id}; proceeding with {operation}"
);
}
ThreadShutdownResult::TimedOut => {
warn!("thread {thread_id} shutdown timed out; proceeding with archive");
warn!("thread {thread_id} shutdown timed out; proceeding with {operation}");
}
}
}
@@ -1294,23 +1319,7 @@ impl ThreadRequestProcessor {
let thread_id = ThreadId::from_string(&params.thread_id)
.map_err(|err| invalid_request(format!("invalid session id: {err}")))?;
let mut thread_ids = vec![thread_id];
if let Some(state_db_ctx) = self.state_db.as_ref() {
let descendants = state_db_ctx
.list_thread_spawn_descendants(thread_id)
.await
.map_err(|err| {
internal_error(format!(
"failed to list spawned descendants for session {thread_id}: {err}"
))
})?;
let mut seen = HashSet::from([thread_id]);
for descendant_id in descendants {
if seen.insert(descendant_id) {
thread_ids.push(descendant_id);
}
}
}
let thread_ids = self.state_db_spawn_subtree_thread_ids(thread_id).await?;
let mut archive_thread_ids = Vec::new();
match self
@@ -1395,6 +1404,31 @@ impl ThreadRequestProcessor {
Ok((ThreadArchiveResponse {}, archived_thread_ids))
}
pub(super) async fn state_db_spawn_subtree_thread_ids(
&self,
thread_id: ThreadId,
) -> Result<Vec<ThreadId>, JSONRPCErrorError> {
let mut thread_ids = vec![thread_id];
let Some(state_db_ctx) = self.state_db.as_ref() else {
return Ok(thread_ids);
};
let mut seen = HashSet::from([thread_id]);
let descendants = state_db_ctx
.list_thread_spawn_descendants(thread_id)
.await
.map_err(|err| {
internal_error(format!(
"failed to list spawned descendants for thread id {thread_id}: {err}"
))
})?;
for descendant_id in descendants {
if seen.insert(descendant_id) {
thread_ids.push(descendant_id);
}
}
Ok(thread_ids)
}
async fn thread_increment_elicitation_inner(
&self,
params: ThreadIncrementElicitationParams,
@@ -1725,6 +1759,54 @@ impl ThreadRequestProcessor {
Ok(ThreadBackgroundTerminalsCleanResponse {})
}
async fn thread_background_terminals_list_inner(
&self,
params: ThreadBackgroundTerminalsListParams,
) -> Result<ThreadBackgroundTerminalsListResponse, JSONRPCErrorError> {
let ThreadBackgroundTerminalsListParams {
thread_id,
cursor,
limit,
} = params;
let (_, thread) = self.load_thread(&thread_id).await?;
let terminals = thread
.list_background_terminals()
.await
.into_iter()
.map(|terminal| ThreadBackgroundTerminal {
item_id: terminal.item_id,
process_id: terminal.process_id,
command: terminal.command,
cwd: terminal.cwd,
os_pid: None,
cpu_percent: None,
rss_kb: None,
})
.collect::<Vec<_>>();
let (data, next_cursor) = paginate_background_terminals(&terminals, cursor, limit)?;
Ok(ThreadBackgroundTerminalsListResponse { data, next_cursor })
}
async fn thread_background_terminals_terminate_inner(
&self,
params: ThreadBackgroundTerminalsTerminateParams,
) -> Result<ThreadBackgroundTerminalsTerminateResponse, JSONRPCErrorError> {
let ThreadBackgroundTerminalsTerminateParams {
thread_id,
process_id,
} = params;
let process_id = process_id.parse::<i32>().map_err(|err| {
invalid_request(format!("invalid background terminal process id: {err}"))
})?;
let (_, thread) = self.load_thread(&thread_id).await?;
let terminated = thread.terminate_background_terminal(process_id).await;
Ok(ThreadBackgroundTerminalsTerminateResponse { terminated })
}
async fn thread_shell_command_inner(
&self,
request_id: &ConnectionRequestId,
@@ -3843,7 +3925,7 @@ fn thread_read_view_error(err: ThreadReadViewError) -> JSONRPCErrorError {
}
}
fn unsupported_thread_store_operation(operation: &'static str) -> JSONRPCErrorError {
pub(super) fn unsupported_thread_store_operation(operation: &'static str) -> JSONRPCErrorError {
method_not_found(format!("{operation} is not supported yet"))
}
@@ -3964,7 +4046,7 @@ fn conversation_summary_rollout_path_read_error(
}
}
fn core_thread_write_error(operation: &str, err: CodexErr) -> JSONRPCErrorError {
pub(super) fn core_thread_write_error(operation: &str, err: CodexErr) -> JSONRPCErrorError {
match err {
CodexErr::ThreadNotFound(thread_id) => {
invalid_request(format!("thread not found: {thread_id}"))
@@ -4250,6 +4332,34 @@ fn build_thread_from_snapshot(
}
}
fn paginate_background_terminals(
terminals: &[ThreadBackgroundTerminal],
cursor: Option<String>,
limit: Option<u32>,
) -> Result<(Vec<ThreadBackgroundTerminal>, Option<String>), JSONRPCErrorError> {
let start = match cursor {
Some(cursor) => {
let cursor = cursor
.parse::<i32>()
.map_err(|err| invalid_request(format!("invalid cursor: {err}")))?;
terminals
.iter()
.position(|terminal| {
terminal
.process_id
.parse::<i32>()
.is_ok_and(|process_id| process_id > cursor)
})
.unwrap_or(terminals.len())
}
None => 0,
};
let effective_limit = limit.unwrap_or(terminals.len() as u32).max(1) as usize;
let end = start.saturating_add(effective_limit).min(terminals.len());
let next_cursor = (end < terminals.len()).then(|| terminals[end - 1].process_id.clone());
Ok((terminals[start..end].to_vec(), next_cursor))
}
fn build_thread_from_loaded_snapshot(
thread_id: ThreadId,
config_snapshot: &ThreadConfigSnapshot,
@@ -36,6 +36,65 @@ mod thread_list_cwd_filter_tests {
}
}
mod background_terminal_pagination_tests {
use super::super::paginate_background_terminals;
use codex_app_server_protocol::ThreadBackgroundTerminal;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
fn terminal(process_id: &str) -> ThreadBackgroundTerminal {
let cwd = if cfg!(windows) { r"C:\tmp" } else { "/tmp" };
ThreadBackgroundTerminal {
item_id: format!("item-{process_id}"),
process_id: process_id.to_string(),
command: format!("command-{process_id}"),
cwd: AbsolutePathBuf::from_absolute_path(cwd).expect("absolute cwd"),
os_pid: None,
cpu_percent: None,
rss_kb: None,
}
}
#[test]
fn paginates_with_process_id_cursor() {
let terminals = vec![
terminal("1"),
terminal("2"),
terminal("3"),
terminal("4"),
terminal("5"),
];
let (data, next_cursor) =
paginate_background_terminals(&terminals, /*cursor*/ None, Some(2))
.expect("valid page");
assert_eq!(data, vec![terminal("1"), terminal("2")]);
assert_eq!(next_cursor, Some("2".to_string()));
let first_cursor = next_cursor;
let terminals_without_anchor = vec![terminal("1"), terminal("3"), terminal("4")];
let (data, next_cursor) =
paginate_background_terminals(&terminals_without_anchor, first_cursor.clone(), Some(2))
.expect("valid page");
assert_eq!(data, vec![terminal("3"), terminal("4")]);
assert_eq!(next_cursor, None);
let (data, next_cursor) =
paginate_background_terminals(&terminals, first_cursor, Some(2)).expect("valid page");
assert_eq!(data, vec![terminal("3"), terminal("4")]);
assert_eq!(next_cursor, Some("4".to_string()));
assert!(
paginate_background_terminals(&terminals, Some("missing".to_string()), Some(1))
.is_err()
);
}
}
mod thread_processor_behavior_tests {
async fn forked_from_id_from_rollout(path: &Path) -> Option<String> {
codex_core::read_session_meta_line(path)
@@ -165,6 +165,7 @@ pub fn write_chatgpt_auth(
last_refresh,
agent_identity: None,
personal_access_token: None,
bedrock_api_key: None,
};
save_auth(codex_home, &auth, cli_auth_credentials_store_mode).context("write auth.json")
@@ -47,6 +47,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
context_window: Some(272_000),
max_context_window: None,
auto_compact_token_limit: None,
comp_hash: None,
effective_context_window_percent: 95,
experimental_supported_tools: Vec::new(),
input_modalities: default_input_modalities(),
@@ -79,6 +79,7 @@ use codex_app_server_protocol::SkillsExtraRootsSetParams;
use codex_app_server_protocol::SkillsListParams;
use codex_app_server_protocol::ThreadArchiveParams;
use codex_app_server_protocol::ThreadCompactStartParams;
use codex_app_server_protocol::ThreadDeleteParams;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadInjectItemsParams;
use codex_app_server_protocol::ThreadListParams;
@@ -456,6 +457,15 @@ impl TestAppServer {
self.send_request("thread/archive", params).await
}
/// Send a `thread/delete` JSON-RPC request.
pub async fn send_thread_delete_request(
&mut self,
params: ThreadDeleteParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("thread/delete", params).await
}
/// Send a `thread/name/set` JSON-RPC request.
pub async fn send_thread_set_name_request(
&mut self,
@@ -119,6 +119,7 @@ async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> {
last_refresh: None,
agent_identity: None,
personal_access_token: None,
bedrock_api_key: None,
},
AuthCredentialsStoreMode::File,
)?;
@@ -112,6 +112,7 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result
assert_eq!(metadata["origin"].as_str(), Some("gaas"));
assert_eq!(metadata["thread_source"].as_str(), Some("client-supplied"));
assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str()));
assert!(metadata.get("installation_id").is_some());
assert!(metadata.get("session_id").is_some());
assert_eq!(
metadata["window_id"].as_str(),
@@ -29,6 +29,7 @@ use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_config::types::AuthCredentialsStoreMode;
use codex_features::Feature;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use core_test_support::responses;
@@ -150,7 +151,7 @@ async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<()
write_mock_responses_config_toml(
codex_home.path(),
&server.uri(),
&BTreeMap::default(),
&BTreeMap::from([(Feature::RemoteCompactionV2, false)]),
REMOTE_AUTO_COMPACT_LIMIT,
Some(true),
"mock_provider",
@@ -19,6 +19,8 @@ use codex_app_server_protocol::McpResourceReadResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::UserInput;
use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
@@ -30,9 +32,14 @@ use codex_protocol::protocol::SessionSource;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use rmcp::handler::server::ServerHandler;
use rmcp::model::ListResourcesResult;
use rmcp::model::Meta;
use rmcp::model::PaginatedRequestParams;
use rmcp::model::ProtocolVersion;
use rmcp::model::RawResource;
use rmcp::model::ReadResourceRequestParams;
use rmcp::model::ReadResourceResult;
use rmcp::model::Resource;
use rmcp::model::ResourceContents;
use rmcp::model::ServerCapabilities;
use rmcp::model::ServerInfo;
@@ -41,6 +48,7 @@ use rmcp::service::RoleServer;
use rmcp::transport::StreamableHttpServerConfig;
use rmcp::transport::StreamableHttpService;
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
use serde_json::json;
use tempfile::TempDir;
use tokio::net::TcpListener;
use tokio::task::JoinHandle;
@@ -51,49 +59,34 @@ const TEST_RESOURCE_URI: &str = "test://codex/resource";
const TEST_BLOB_RESOURCE_URI: &str = "test://codex/resource.bin";
const TEST_RESOURCE_BLOB: &str = "YmluYXJ5LXJlc291cmNl";
const TEST_RESOURCE_TEXT: &str = "Resource body from the MCP server.";
const SKILL_NAME: &str = "demo-plugin:deploy";
const RAW_SKILL_DESCRIPTION: &str = "Deploy\nthrough the <hosted> orchestrator.";
const SKILL_DESCRIPTION: &str = "Deploy through the &lt;hosted&gt; orchestrator.";
const SKILL_RESOURCE_URI: &str = "skill://plugin_demo/deploy";
const SKILL_MAIN_PROMPT_URI: &str = "skill://plugin_demo/deploy/SKILL.md";
const SKILL_REFERENCE_URI: &str = "skill://plugin_demo/deploy/references/deploy.md";
const SKILL_MARKER: &str = "ORCHESTRATOR_SKILL_BODY_MARKER";
const SKILL_CONTENTS: &str = concat!(
"---\n",
"name: deploy\n",
"description: Deploy through the orchestrator.\n",
"---\n\n",
"# Deploy\n\n",
"ORCHESTRATOR_SKILL_BODY_MARKER\n\n",
"Read the [deployment reference](skill://plugin_demo/deploy/references/deploy.md).\n",
);
const SKILL_REFERENCE_CONTENTS: &str =
"# Deploy reference\n\nUse the orchestrator deployment API.\n";
const SKILLS_LIST_CALL_ID: &str = "skills-list";
const SKILLS_READ_CALL_ID: &str = "skills-read";
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mcp_resource_read_returns_resource_contents() -> Result<()> {
let responses_server = responses::start_mock_server().await;
let (apps_server_url, apps_server_handle) = start_resource_apps_mcp_server().await?;
let codex_home = TempDir::new()?;
let responses_server_uri = responses_server.uri();
std::fs::write(
codex_home.path().join("config.toml"),
format!(
r#"
model = "mock-model"
approval_policy = "untrusted"
sandbox_mode = "read-only"
model_provider = "mock_provider"
chatgpt_base_url = "{apps_server_url}"
mcp_oauth_credentials_store = "file"
[features]
apps = true
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{responses_server_uri}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
"#
),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let (_codex_home, mut mcp) =
start_resource_test_app_server(&apps_server_url, &responses_server_uri).await?;
let thread_start_id = mcp
.send_thread_start_request(ThreadStartParams {
@@ -120,7 +113,6 @@ stream_max_retries = 0
mcp.read_stream_until_response_message(RequestId::Integer(read_request_id)),
)
.await??;
assert_eq!(
to_response::<McpResourceReadResponse>(read_response)?,
expected_resource_read_response()
@@ -131,6 +123,165 @@ stream_max_retries = 0
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "orchestrator skills are disabled until /ps/mcp serves complete skill packages"]
async fn orchestrator_skill_can_read_referenced_resource_without_an_executor() -> Result<()> {
let responses_server = responses::start_mock_server().await;
let (apps_server_url, apps_server_handle) = start_resource_apps_mcp_server().await?;
let responses_server_uri = responses_server.uri();
let (_codex_home, mut mcp) =
start_resource_test_app_server(&apps_server_url, &responses_server_uri).await?;
let thread_start_id = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
environments: Some(Vec::new()),
..Default::default()
})
.await?;
let thread_start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?;
let response_mock = responses::mount_sse_sequence(
&responses_server,
vec![
responses::sse(vec![
responses::ev_response_created("resp-skills-list"),
responses::ev_function_call_with_namespace(
SKILLS_LIST_CALL_ID,
"skills",
"list",
&json!({
"authority": {
"kind": "orchestrator",
},
})
.to_string(),
),
responses::ev_completed("resp-skills-list"),
]),
responses::sse(vec![
responses::ev_response_created("resp-skills-read"),
responses::ev_function_call_with_namespace(
SKILLS_READ_CALL_ID,
"skills",
"read",
&json!({
"authority": {
"kind": "orchestrator",
},
"package": SKILL_RESOURCE_URI,
"resource": SKILL_REFERENCE_URI,
})
.to_string(),
),
responses::ev_completed("resp-skills-read"),
]),
responses::sse(vec![
responses::ev_response_created("resp-orchestrator-skill"),
responses::ev_assistant_message("msg-orchestrator-skill", "Done"),
responses::ev_completed("resp-orchestrator-skill"),
]),
],
)
.await;
let turn_start_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
input: vec![UserInput::Text {
text: format!("Use ${SKILL_NAME}"),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_start_id)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let requests = response_mock.requests();
assert_eq!(requests.len(), 3);
let first_request = &requests[0];
assert!(first_request.tool_by_name("skills", "list").is_some());
assert!(first_request.tool_by_name("skills", "read").is_some());
assert!(first_request.tool_by_name("skills", "search").is_none());
let developer_messages = first_request.message_input_texts("developer");
let catalog_line = format!(
"- {SKILL_NAME}: {SKILL_DESCRIPTION} (orchestrator resource: {SKILL_RESOURCE_URI})"
);
assert_eq!(
1,
developer_messages
.iter()
.filter(|text| text.contains(&catalog_line))
.count()
);
assert!(
developer_messages
.iter()
.all(|text| !text.contains("ignored-plugin:ignored"))
);
assert!(
developer_messages
.iter()
.any(|text| text.contains("do not treat `skill://` identifiers as filesystem paths"))
);
let skill_fragments = first_request
.message_input_texts("user")
.into_iter()
.filter(|text| text.starts_with("<skill>"))
.collect::<Vec<_>>();
assert_eq!(1, skill_fragments.len());
assert!(skill_fragments[0].contains(&format!("<name>{SKILL_NAME}</name>")));
assert!(skill_fragments[0].contains(SKILL_MARKER));
assert!(skill_fragments[0].contains(SKILL_REFERENCE_URI));
let list_output = requests[1]
.function_call_output_text(SKILLS_LIST_CALL_ID)
.ok_or_else(|| anyhow::anyhow!("skills.list output should be sent to the model"))?;
assert_eq!(
serde_json::from_str::<serde_json::Value>(&list_output)?,
json!({
"skills": [{
"authority": {
"kind": "orchestrator",
},
"package": SKILL_RESOURCE_URI,
"name": SKILL_NAME,
"description": SKILL_DESCRIPTION,
"main_resource": SKILL_MAIN_PROMPT_URI,
}],
"warnings": ["Orchestrator skill discovery stopped after 2 resource pages: failed to list orchestrator skill resources: resources/list failed for `codex_apps`: Mcp error: -32603: simulated later-page failure"],
})
);
let read_output = requests[2]
.function_call_output_text(SKILLS_READ_CALL_ID)
.ok_or_else(|| anyhow::anyhow!("skills.read output should be sent to the model"))?;
assert_eq!(
serde_json::from_str::<serde_json::Value>(&read_output)?,
json!({
"resource": SKILL_REFERENCE_URI,
"contents": SKILL_REFERENCE_CONTENTS,
})
);
apps_server_handle.abort();
let _ = apps_server_handle.await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn mcp_resource_read_returns_resource_contents_without_thread() -> Result<()> {
let (apps_server_url, apps_server_handle) = start_resource_apps_mcp_server().await?;
@@ -246,6 +397,52 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> {
Ok(())
}
async fn start_resource_test_app_server(
apps_server_url: &str,
responses_server_uri: &str,
) -> Result<(TempDir, TestAppServer)> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
format!(
r#"
model = "mock-model"
approval_policy = "untrusted"
sandbox_mode = "read-only"
model_provider = "mock_provider"
chatgpt_base_url = "{apps_server_url}"
mcp_oauth_credentials_store = "file"
[features]
apps = true
[skills]
include_instructions = true
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{responses_server_uri}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
"#
),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
Ok((codex_home, mcp))
}
async fn start_resource_apps_mcp_server() -> Result<(String, JoinHandle<()>)> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
@@ -292,12 +489,79 @@ impl ServerHandler for ResourceAppsMcpServer {
.with_protocol_version(ProtocolVersion::V_2025_06_18)
}
async fn list_resources(
&self,
request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListResourcesResult, rmcp::ErrorData> {
let cursor = request.and_then(|request| request.cursor);
if cursor.is_none() {
return Ok(ListResourcesResult {
resources: vec![skill_resource(
"skill://plugin_ignored/ignored",
"plugin_ignored/ignored",
"Not an MCP skill resource.",
"text/plain",
"ignored-plugin",
"ignored",
)],
next_cursor: Some("skills-page".to_string()),
meta: None,
});
}
if cursor.as_deref() == Some("failing-page") {
return Err(rmcp::ErrorData::internal_error(
"simulated later-page failure",
/*data*/ None,
));
}
if cursor.as_deref() != Some("skills-page") {
return Err(rmcp::ErrorData::invalid_params(
"unexpected resources/list cursor",
/*data*/ None,
));
}
Ok(ListResourcesResult {
resources: vec![skill_resource(
SKILL_RESOURCE_URI,
"plugin_demo/deploy",
RAW_SKILL_DESCRIPTION,
"mcp/skill",
"demo-plugin",
"deploy",
)],
next_cursor: Some("failing-page".to_string()),
meta: None,
})
}
async fn read_resource(
&self,
request: ReadResourceRequestParams,
_context: RequestContext<RoleServer>,
) -> Result<ReadResourceResult, rmcp::ErrorData> {
let uri = request.uri;
if uri == SKILL_MAIN_PROMPT_URI {
return Ok(ReadResourceResult::new(vec![
ResourceContents::TextResourceContents {
uri: SKILL_MAIN_PROMPT_URI.to_string(),
mime_type: Some("text/markdown".to_string()),
text: SKILL_CONTENTS.to_string(),
meta: None,
},
]));
}
if uri == SKILL_REFERENCE_URI {
return Ok(ReadResourceResult::new(vec![
ResourceContents::TextResourceContents {
uri: SKILL_REFERENCE_URI.to_string(),
mime_type: Some("text/markdown".to_string()),
text: SKILL_REFERENCE_CONTENTS.to_string(),
meta: None,
},
]));
}
if uri != TEST_RESOURCE_URI {
return Err(rmcp::ErrorData::resource_not_found(
format!("resource not found: {uri}"),
@@ -321,3 +585,27 @@ impl ServerHandler for ResourceAppsMcpServer {
]))
}
}
fn skill_resource(
uri: &str,
name: &str,
description: &str,
mime_type: &str,
plugin_name: &str,
skill_name: &str,
) -> Resource {
Resource::new(
RawResource::new(uri, name)
.with_description(description)
.with_mime_type(mime_type)
.with_meta(skill_resource_meta(plugin_name, skill_name)),
/*annotations*/ None,
)
}
fn skill_resource_meta(plugin_name: &str, skill_name: &str) -> Meta {
Meta(serde_json::Map::from_iter([
("plugin_name".to_string(), json!(plugin_name)),
("skill_name".to_string(), json!(skill_name)),
]))
}
@@ -50,6 +50,7 @@ mod review;
mod safety_check_downgrade;
mod skills_list;
mod thread_archive;
mod thread_delete;
mod thread_fork;
mod thread_inject_items;
mod thread_list;
@@ -292,6 +292,14 @@ async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()>
async fn plugin_install_uses_remote_apps_needing_auth_response() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
let remote_app_manifest = json!({
"apps": {
"alpha": {
"id": "alpha",
"category": "Developer Tools"
}
}
});
let bundle_url = mount_remote_plugin_bundle(
&server,
/*status_code*/ 200,
@@ -299,7 +307,14 @@ async fn plugin_install_uses_remote_apps_needing_auth_response() -> Result<()> {
)
.await;
configure_remote_plugin_with_apps_test(codex_home.path(), &server)?;
mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await;
mount_remote_plugin_detail_with_app_manifest(
&server,
REMOTE_PLUGIN_ID,
"1.2.3",
Some(&bundle_url),
remote_app_manifest,
)
.await;
mount_empty_remote_installed_plugins(&server).await;
mount_remote_plugin_install_with_apps_needing_auth(&server, REMOTE_PLUGIN_ID, &["alpha"]).await;
@@ -327,7 +342,7 @@ async fn plugin_install_uses_remote_apps_needing_auth_response() -> Result<()> {
name: "alpha".to_string(),
description: None,
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
needs_auth: true,
category: Some("Developer Tools".to_string()),
}],
}
);
@@ -1001,7 +1016,7 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> {
name: "Alpha".to_string(),
description: Some("Alpha connector".to_string()),
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
needs_auth: true,
category: None,
}],
}
);
@@ -1089,7 +1104,7 @@ async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> {
name: "Alpha".to_string(),
description: Some("Alpha connector".to_string()),
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
needs_auth: true,
category: None,
}],
}
);
@@ -1,4 +1,3 @@
use std::borrow::Cow;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::time::Duration;
@@ -17,6 +16,7 @@ use axum::http::Uri;
use axum::http::header::AUTHORIZATION;
use axum::routing::get;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::AppMetadata;
use codex_app_server_protocol::AppTemplateSummary;
use codex_app_server_protocol::AppTemplateUnavailableReason;
use codex_app_server_protocol::HookEventName;
@@ -37,17 +37,6 @@ use codex_app_server_protocol::RequestId;
use codex_config::types::AuthCredentialsStoreMode;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use rmcp::handler::server::ServerHandler;
use rmcp::model::JsonObject;
use rmcp::model::ListToolsResult;
use rmcp::model::Meta;
use rmcp::model::ServerCapabilities;
use rmcp::model::ServerInfo;
use rmcp::model::Tool;
use rmcp::model::ToolAnnotations;
use rmcp::transport::StreamableHttpServerConfig;
use rmcp::transport::StreamableHttpService;
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
use serde_json::json;
use tempfile::TempDir;
use tokio::net::TcpListener;
@@ -434,6 +423,7 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
"template_id": "templated_apps_GitHubEnterprise",
"name": "GitHub Enterprise",
"description": "Connect GitHub Enterprise",
"category": "Developer Tools",
"canonical_connector_id": "github_enterprise",
"logo_url": "https://example.com/ghe-light.png",
"logo_url_dark": "https://example.com/ghe-dark.png",
@@ -595,6 +585,7 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
template_id: "templated_apps_GitHubEnterprise".to_string(),
name: "GitHub Enterprise".to_string(),
description: Some("Connect GitHub Enterprise".to_string()),
category: Some("Developer Tools".to_string()),
canonical_connector_id: Some("github_enterprise".to_string()),
logo_url: Some("https://example.com/ghe-light.png".to_string()),
logo_url_dark: Some("https://example.com/ghe-dark.png".to_string()),
@@ -605,6 +596,7 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
template_id: "templated_apps_Databricks".to_string(),
name: "Databricks".to_string(),
description: None,
category: None,
canonical_connector_id: None,
logo_url: None,
logo_url_dark: None,
@@ -1313,7 +1305,8 @@ description: Visible only for ChatGPT
r#"{
"apps": {
"gmail": {
"id": "gmail"
"id": "gmail",
"category": "Communication"
}
}
}"#,
@@ -1483,14 +1476,17 @@ enabled = false
response.plugin.apps[0].install_url.as_deref(),
Some("https://chatgpt.com/apps/gmail/gmail")
);
assert_eq!(response.plugin.apps[0].needs_auth, true);
assert_eq!(
response.plugin.apps[0].category.as_deref(),
Some("Communication")
);
assert_eq!(response.plugin.mcp_servers.len(), 1);
assert_eq!(response.plugin.mcp_servers[0], "demo");
Ok(())
}
#[tokio::test]
async fn plugin_read_returns_app_needs_auth() -> Result<()> {
async fn plugin_read_returns_app_metadata_category() -> Result<()> {
let connectors = vec![
AppInfo {
id: "alpha".to_string(),
@@ -1500,7 +1496,20 @@ async fn plugin_read_returns_app_needs_auth() -> Result<()> {
logo_url_dark: None,
distribution_channel: Some("featured".to_string()),
branding: None,
app_metadata: None,
app_metadata: Some(AppMetadata {
review: None,
categories: Some(vec!["Productivity".to_string()]),
sub_categories: None,
seo_description: None,
screenshots: None,
developer: None,
version: None,
version_id: None,
version_notes: None,
first_party_type: None,
first_party_requires_install: None,
show_in_composer_when_unlinked: None,
}),
labels: None,
install_url: None,
is_accessible: false,
@@ -1523,8 +1532,7 @@ async fn plugin_read_returns_app_needs_auth() -> Result<()> {
plugin_display_names: Vec::new(),
},
];
let tools = vec![connector_tool("beta", "Beta App")?];
let (server_url, server_handle) = start_apps_server(connectors, tools).await?;
let (server_url, server_handle) = start_apps_server(connectors).await?;
let codex_home = TempDir::new()?;
write_connectors_config(codex_home.path(), &server_url)?;
@@ -1571,9 +1579,9 @@ async fn plugin_read_returns_app_needs_auth() -> Result<()> {
.plugin
.apps
.iter()
.map(|app| (app.id.as_str(), app.needs_auth))
.map(|app| (app.id.as_str(), app.category.as_deref()))
.collect::<Vec<_>>(),
vec![("alpha", true), ("beta", false)]
vec![("alpha", Some("Productivity")), ("beta", None)]
);
server_handle.abort();
@@ -1851,70 +1859,22 @@ struct AppsServerState {
response: Arc<StdMutex<serde_json::Value>>,
}
#[derive(Clone)]
struct PluginReadMcpServer {
tools: Arc<StdMutex<Vec<Tool>>>,
}
impl ServerHandler for PluginReadMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
}
fn list_tools(
&self,
_request: Option<rmcp::model::PaginatedRequestParams>,
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
) -> impl std::future::Future<Output = Result<ListToolsResult, rmcp::ErrorData>> + Send + '_
{
let tools = self.tools.clone();
async move {
let tools = tools
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
Ok(ListToolsResult {
tools,
next_cursor: None,
meta: None,
})
}
}
}
async fn start_apps_server(
connectors: Vec<AppInfo>,
tools: Vec<Tool>,
) -> Result<(String, JoinHandle<()>)> {
async fn start_apps_server(connectors: Vec<AppInfo>) -> Result<(String, JoinHandle<()>)> {
let state = Arc::new(AppsServerState {
response: Arc::new(StdMutex::new(
json!({ "apps": connectors, "next_token": null }),
)),
});
let tools = Arc::new(StdMutex::new(tools));
let listener = TcpListener::bind("127.0.0.1:0").await?;
let addr = listener.local_addr()?;
let mcp_service = StreamableHttpService::new(
{
let tools = tools.clone();
move || {
Ok(PluginReadMcpServer {
tools: tools.clone(),
})
}
},
Arc::new(LocalSessionManager::default()),
StreamableHttpServerConfig::default(),
);
let router = Router::new()
.route("/connectors/directory/list", get(list_directory_connectors))
.route(
"/connectors/directory/list_workspace",
get(list_directory_connectors),
)
.with_state(state)
.nest_service("/api/codex/ps/mcp", mcp_service);
.with_state(state);
let handle = tokio::spawn(async move {
let _ = axum::serve(listener, router).await;
@@ -1954,27 +1914,6 @@ async fn list_directory_connectors(
}
}
fn connector_tool(connector_id: &str, connector_name: &str) -> Result<Tool> {
let schema: JsonObject = serde_json::from_value(json!({
"type": "object",
"additionalProperties": false
}))?;
let mut tool = Tool::new(
Cow::Owned(format!("connector_{connector_id}")),
Cow::Borrowed("Connector test tool"),
Arc::new(schema),
);
tool.annotations = Some(ToolAnnotations::new().read_only(true));
let mut meta = Meta::new();
meta.0
.insert("connector_id".to_string(), json!(connector_id));
meta.0
.insert("connector_name".to_string(), json!(connector_name));
tool.meta = Some(meta);
Ok(tool)
}
fn write_connectors_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> {
std::fs::write(
codex_home.join("config.toml"),
@@ -36,7 +36,10 @@ use codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStartedNotification;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_features::FEATURES;
use codex_features::Feature;
use codex_protocol::protocol::RealtimeConversationVersion;
@@ -1342,6 +1345,136 @@ async fn webrtc_v1_handoff_request_delegates_and_appends_result() -> Result<()>
Ok(())
}
#[tokio::test]
async fn webrtc_assistant_output_without_handoff_reaches_realtime() -> Result<()> {
skip_if_no_network!(Ok(()));
let final_answer = "long output ".repeat(1_000);
for (version, expected_version, preamble) in [
(
RealtimeTestVersion::V1,
RealtimeConversationVersion::V1,
"direct preamble from v1",
),
(
RealtimeTestVersion::V2,
RealtimeConversationVersion::V2,
"direct preamble from v2",
),
] {
let mut harness = RealtimeE2eHarness::new(
version,
main_loop_responses(vec![responses::sse(vec![
responses::ev_response_created("resp-1"),
json!({
"type": "response.output_item.done",
"item": {
"type": "message",
"role": "assistant",
"id": "msg-preamble",
"phase": "commentary",
"content": [{"type": "output_text", "text": preamble}]
}
}),
responses::ev_assistant_message("msg-final", &final_answer),
responses::ev_completed("resp-1"),
])]),
realtime_sideband(vec![realtime_sideband_connection(vec![
vec![session_updated("sess_standalone_output")],
vec![],
match version {
RealtimeTestVersion::V1 => vec![],
RealtimeTestVersion::V2 => vec![
json!({
"type": "response.created",
"response": { "id": "resp_preamble" }
}),
json!({
"type": "response.done",
"response": { "id": "resp_preamble" }
}),
],
},
vec![],
vec![],
])]),
)
.await?;
let started = harness.start_webrtc_realtime("v=offer\r\n").await?;
assert_eq!(started.started.version, expected_version);
let request_id = harness
.mcp
.send_turn_start_request(TurnStartParams {
thread_id: harness.thread_id.clone(),
input: vec![V2UserInput::Text {
text: "direct text turn".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
harness
.mcp
.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let _: TurnStartResponse = to_response(response)?;
let _ = harness
.read_notification::<TurnCompletedNotification>("turn/completed")
.await?;
let preamble_request = harness.sideband_outbound_request(/*request_index*/ 1).await;
let output_text = match version {
RealtimeTestVersion::V1 => {
let final_request = harness.sideband_outbound_request(/*request_index*/ 2).await;
assert_eq!(
preamble_request,
json!({
"type": "conversation.handoff.append",
"handoff_id": "codex",
"output_text": preamble,
})
);
assert_eq!(final_request["type"], "conversation.handoff.append");
assert_eq!(final_request["handoff_id"], "codex");
final_request["output_text"]
.as_str()
.expect("output text")
.to_string()
}
RealtimeTestVersion::V2 => {
assert_v2_progress_update(&preamble_request, preamble);
assert_v2_response_create(
&harness.sideband_outbound_request(/*request_index*/ 2).await,
);
let final_request = harness.sideband_outbound_request(/*request_index*/ 3).await;
assert_eq!(final_request["type"], "conversation.item.create");
assert_eq!(final_request["item"]["type"], "message");
assert_eq!(final_request["item"]["role"], "user");
assert_eq!(final_request["item"]["content"][0]["type"], "input_text");
let output_text = final_request["item"]["content"][0]["text"]
.as_str()
.expect("output text");
assert!(output_text.starts_with("[BACKEND] "));
assert_v2_response_create(
&harness.sideband_outbound_request(/*request_index*/ 4).await,
);
output_text.to_string()
}
};
assert!(output_text.contains("tokens truncated"));
assert!(output_text.len() <= 4_000);
harness.shutdown().await;
}
Ok(())
}
#[tokio::test]
async fn webrtc_v2_forwards_audio_and_text_between_client_and_sideband() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -20,6 +20,7 @@ use std::sync::Arc;
use anyhow::Result;
use app_test_support::create_mock_responses_server_repeating_assistant;
use codex_app_server::in_process;
use codex_app_server::in_process::InProcessClientHandle;
use codex_app_server::in_process::InProcessServerEvent;
use codex_app_server::in_process::InProcessStartArgs;
use codex_app_server_protocol::ClientInfo;
@@ -27,6 +28,8 @@ use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadDeleteParams;
use codex_app_server_protocol::ThreadDeleteResponse;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadResumeParams;
@@ -42,8 +45,14 @@ use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_exec_server::EnvironmentManager;
use codex_feedback::CodexFeedback;
use codex_protocol::ThreadId;
use codex_protocol::models::BaseInstructions;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadMemoryMode;
use codex_thread_store::CreateThreadParams as StoreCreateThreadParams;
use codex_thread_store::InMemoryThreadStore;
use codex_thread_store::ThreadPersistenceMetadata;
use codex_thread_store::ThreadStore;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -52,7 +61,7 @@ use uuid::Uuid;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test]
async fn thread_start_with_non_local_thread_store_does_not_create_local_persistence() -> Result<()>
async fn thread_delete_with_non_local_thread_store_does_not_create_local_persistence() -> Result<()>
{
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
@@ -61,43 +70,10 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste
// here so this regression stays focused on thread persistence artifacts.
create_config_toml_with_thread_store(codex_home.path(), &server.uri(), &store_id)?;
let loader_overrides = LoaderOverrides::without_managed_config_for_tests();
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.loader_overrides(loader_overrides.clone())
.build()
.await?;
let thread_store = InMemoryThreadStore::for_id(store_id.clone());
let _in_memory_store = InMemoryThreadStoreId { store_id };
let mut client = in_process::start(InProcessStartArgs {
arg0_paths: Arg0DispatchPaths::default(),
config: Arc::new(config),
cli_overrides: Vec::new(),
loader_overrides,
strict_config: false,
cloud_config_bundle: CloudConfigBundleLoader::default(),
thread_config_loader: Arc::new(NoopThreadConfigLoader),
feedback: CodexFeedback::new(),
log_db: None,
state_db: None,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
config_warnings: Vec::new(),
session_source: SessionSource::Cli,
enable_codex_api_key_env: false,
initialize: InitializeParams {
client_info: ClientInfo {
name: "codex-app-server-tests".to_string(),
title: None,
version: "0.1.0".to_string(),
},
capabilities: None,
},
channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY,
})
.await?;
let mut client = start_in_process_server(codex_home.path()).await?;
let response = client
.request(ClientRequest::ThreadStart {
@@ -166,11 +142,39 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste
assert_eq!(data[0].id, thread.id);
assert_eq!(data[0].path, None);
delete_thread(&client, /*request_id*/ 4, thread.id.clone()).await?;
let unloaded_thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?;
thread_store
.create_thread(StoreCreateThreadParams {
thread_id: unloaded_thread_id,
extra_config: None,
forked_from_id: None,
parent_thread_id: None,
source: SessionSource::Cli,
thread_source: None,
base_instructions: BaseInstructions::default(),
dynamic_tools: Vec::new(),
multi_agent_version: None,
metadata: ThreadPersistenceMetadata {
cwd: Some(codex_home.path().to_path_buf()),
model_provider: "mock_provider".to_string(),
memory_mode: ThreadMemoryMode::Enabled,
},
})
.await?;
delete_thread(
&client,
/*request_id*/ 5,
unloaded_thread_id.to_string(),
)
.await?;
client.shutdown().await?;
let calls = thread_store.calls().await;
assert_eq!(calls.create_thread, 1);
assert_eq!(calls.create_thread, 2);
assert_eq!(calls.list_threads, 1);
assert_eq!(calls.delete_thread, 2);
assert!(
calls.append_items > 0,
"turn/start should append rollout items through the injected store"
@@ -269,10 +273,24 @@ async fn cold_thread_resume_reuses_non_local_history_probe() -> Result<()> {
Ok(())
}
async fn start_in_process_server(codex_home: &Path) -> Result<InProcessClientHandle> {
let loader_overrides = LoaderOverrides::without_managed_config_for_tests();
let config = Arc::new(
ConfigBuilder::default()
.codex_home(codex_home.to_path_buf())
.fallback_cwd(Some(codex_home.to_path_buf()))
.loader_overrides(loader_overrides.clone())
.build()
.await?,
);
Ok(start_in_process_client(config, loader_overrides).await?)
}
async fn start_in_process_client(
config: Arc<Config>,
loader_overrides: LoaderOverrides,
) -> std::io::Result<in_process::InProcessClientHandle> {
) -> std::io::Result<InProcessClientHandle> {
in_process::start(InProcessStartArgs {
arg0_paths: Arg0DispatchPaths::default(),
config,
@@ -301,6 +319,22 @@ async fn start_in_process_client(
.await
}
async fn delete_thread(
client: &InProcessClientHandle,
request_id: i64,
thread_id: String,
) -> Result<()> {
let response = client
.request(ClientRequest::ThreadDelete {
request_id: RequestId::Integer(request_id),
params: ThreadDeleteParams { thread_id },
})
.await?
.map_err(|error| anyhow::anyhow!("thread/delete failed: {}", error.message))?;
let _: ThreadDeleteResponse = serde_json::from_value(response)?;
Ok(())
}
fn assert_no_local_persistence_artifacts(codex_home: &Path) -> Result<()> {
// These are the observable tripwires for accidental local persistence. If a
// future code path constructs a local rollout/session store or opens the
@@ -0,0 +1,200 @@
use anyhow::Result;
use app_test_support::TestAppServer;
use app_test_support::create_fake_rollout;
use app_test_support::to_response;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadDeleteParams;
use codex_app_server_protocol::ThreadDeleteResponse;
use codex_app_server_protocol::ThreadDeletedNotification;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadLoadedListResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_core::find_thread_path_by_id_str;
use codex_protocol::ThreadId;
use codex_state::DirectionalThreadSpawnEdgeStatus;
use codex_state::StateRuntime;
use pretty_assertions::assert_eq;
use std::path::Path;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test]
async fn thread_delete_deletes_spawned_descendants() -> Result<()> {
let codex_home = TempDir::new()?;
let parent_id = create_delete_test_rollout(codex_home.path(), /*minute*/ 0, "parent")?;
let child_id = create_delete_test_rollout(codex_home.path(), /*minute*/ 1, "child")?;
let grandchild_id =
create_delete_test_rollout(codex_home.path(), /*minute*/ 2, "grandchild")?;
let state_db =
StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into()).await?;
let parent_thread_id = ThreadId::from_string(&parent_id)?;
let child_thread_id = ThreadId::from_string(&child_id)?;
let grandchild_thread_id = ThreadId::from_string(&grandchild_id)?;
for (parent, child, status) in [
(
parent_thread_id,
child_thread_id,
DirectionalThreadSpawnEdgeStatus::Closed,
),
(
child_thread_id,
grandchild_thread_id,
DirectionalThreadSpawnEdgeStatus::Open,
),
] {
state_db
.upsert_thread_spawn_edge(parent, child, status)
.await?;
}
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let delete_id = mcp
.send_thread_delete_request(ThreadDeleteParams {
thread_id: parent_id.clone(),
})
.await?;
let delete_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(delete_id)),
)
.await??;
let _: ThreadDeleteResponse = to_response::<ThreadDeleteResponse>(delete_resp)?;
let mut deleted_ids = Vec::new();
for _ in 0..3 {
let notification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("thread/deleted"),
)
.await??;
let deleted_notification: ThreadDeletedNotification = serde_json::from_value(
notification
.params
.expect("thread/deleted notification params"),
)?;
deleted_ids.push(deleted_notification.thread_id);
}
assert_eq!(deleted_ids, vec![grandchild_id, child_id, parent_id]);
for thread_id in [parent_thread_id, child_thread_id, grandchild_thread_id] {
let rollout_path = find_thread_path_by_id_str(
codex_home.path(),
&thread_id.to_string(),
/*state_db_ctx*/ None,
)
.await?;
assert!(
rollout_path.is_none(),
"expected active rollout for {thread_id} to be deleted"
);
}
assert_eq!(
state_db
.list_thread_spawn_descendants(parent_thread_id)
.await?,
Vec::<ThreadId>::new()
);
Ok(())
}
fn create_delete_test_rollout(codex_home: &Path, minute: u8, preview: &str) -> Result<String> {
create_fake_rollout(
codex_home,
&format!("2025-01-01T00-{minute:02}-00"),
&format!("2025-01-01T00:{minute:02}:00Z"),
preview,
Some("mock_provider"),
/*git_info*/ None,
)
}
#[tokio::test]
async fn thread_delete_handles_live_threads_before_rollout_exists() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = TestAppServer::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let start_id = mcp
.send_thread_start_request(ThreadStartParams::default())
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
let persisted_thread = to_response::<ThreadStartResponse>(start_resp)?.thread;
let rollout_path = find_thread_path_by_id_str(
codex_home.path(),
&persisted_thread.id,
/*state_db_ctx*/ None,
)
.await?;
assert_eq!(rollout_path, None);
let delete_id = mcp
.send_thread_delete_request(ThreadDeleteParams {
thread_id: persisted_thread.id,
})
.await?;
let delete_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(delete_id)),
)
.await??;
let _: ThreadDeleteResponse = to_response::<ThreadDeleteResponse>(delete_resp)?;
let start_id = mcp
.send_thread_start_request(ThreadStartParams {
ephemeral: Some(true),
..Default::default()
})
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
let delete_id = mcp
.send_thread_delete_request(ThreadDeleteParams {
thread_id: thread.id.clone(),
})
.await?;
let delete_err: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(delete_id)),
)
.await??;
let expected_message = format!(
"thread is not persisted and cannot be deleted: {}",
thread.id
);
assert_eq!(delete_err.error.message, expected_message);
let list_id = mcp
.send_thread_loaded_list_request(ThreadLoadedListParams::default())
.await?;
let list_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(list_id)),
)
.await??;
let ThreadLoadedListResponse { mut data, .. } =
to_response::<ThreadLoadedListResponse>(list_resp)?;
data.sort();
assert_eq!(data, vec![thread.id]);
Ok(())
}
@@ -1,3 +1,4 @@
use anyhow::Context;
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::PathBufExt;
@@ -21,6 +22,8 @@ use codex_app_server_protocol::ThreadStartedNotification;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadStatusChangedNotification;
use codex_app_server_protocol::TurnEnvironmentParams;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_config::loader::project_trust_key;
use codex_config::types::AuthCredentialsStoreMode;
use codex_core::config::set_project_trust_level;
@@ -400,11 +403,13 @@ async fn thread_start_response_excludes_empty_project_instruction_source() -> Re
}
#[tokio::test]
async fn thread_start_without_selected_environment_excludes_instruction_sources() -> Result<()> {
async fn thread_start_without_selected_environment_includes_only_global_instruction_source()
-> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?;
std::fs::write(codex_home.path().join("AGENTS.md"), "global instructions")?;
let global_agents_path = codex_home.path().join("AGENTS.md");
std::fs::write(&global_agents_path, "global instructions")?;
let workspace = TempDir::new()?;
std::fs::write(workspace.path().join("AGENTS.md"), "project instructions")?;
@@ -424,11 +429,56 @@ async fn thread_start_without_selected_environment_excludes_instruction_sources(
)
.await??;
let ThreadStartResponse {
thread,
instruction_sources,
..
} = to_response::<ThreadStartResponse>(response)?;
assert!(instruction_sources.is_empty());
assert_eq!(
instruction_sources
.into_iter()
.map(normalize_path_for_comparison)
.collect::<Vec<_>>(),
vec![normalize_path_for_comparison(std::fs::canonicalize(
global_agents_path,
)?)]
);
let turn_request_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
input: vec![V2UserInput::Text {
text: "inspect instructions".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_request_id)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let requests = server
.received_requests()
.await
.context("failed to fetch received requests")?;
let model_request = requests
.iter()
.find(|request| request.url.path().ends_with("/responses"))
.context("expected model request")?;
let model_request_body = model_request
.body_json::<Value>()
.context("model request body should be JSON")?
.to_string();
assert!(model_request_body.contains("global instructions"));
assert!(!model_request_body.contains("project instructions"));
Ok(())
}
@@ -43,7 +43,12 @@ use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ServerRequestResolvedNotification;
use codex_app_server_protocol::SubAgentActivityKind;
use codex_app_server_protocol::TextElement;
use codex_app_server_protocol::ThreadDeleteParams;
use codex_app_server_protocol::ThreadDeleteResponse;
use codex_app_server_protocol::ThreadDeletedNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadLoadedListResponse;
use codex_app_server_protocol::ThreadSource;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
@@ -3370,6 +3375,49 @@ async fn turn_start_emits_spawn_agent_item_with_model_metadata_v2() -> Result<()
assert_eq!(turn_completed.thread_id, thread.id);
assert_eq!(turn_completed.turn.id, turn.turn.id);
// Reuse this live spawn setup to cover thread/delete's ThreadManager descendant path.
let delete_req = mcp
.send_thread_delete_request(ThreadDeleteParams {
thread_id: thread.id.clone(),
})
.await?;
let delete_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(delete_req)),
)
.await??;
let _: ThreadDeleteResponse = to_response::<ThreadDeleteResponse>(delete_resp)?;
let mut deleted_thread_ids = Vec::new();
for _ in 0..2 {
let deleted_notif = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("thread/deleted"),
)
.await??;
let deleted: ThreadDeletedNotification = serde_json::from_value(
deleted_notif
.params
.expect("thread/deleted notification params"),
)?;
deleted_thread_ids.push(deleted.thread_id);
}
assert_eq!(
deleted_thread_ids,
vec![receiver_thread_id, thread.id.clone()]
);
let list_req = mcp
.send_thread_loaded_list_request(ThreadLoadedListParams::default())
.await?;
let list_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(list_req)),
)
.await??;
let ThreadLoadedListResponse { data, .. } = to_response::<ThreadLoadedListResponse>(list_resp)?;
assert_eq!(data, Vec::<String>::new());
Ok(())
}
+1
View File
@@ -20,6 +20,7 @@ workspace = true
anyhow = { workspace = true }
codex-exec-server = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-path-uri = { workspace = true }
similar = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt"] }
+13 -1
View File
@@ -21,6 +21,7 @@ use crate::parser::Hunk;
use crate::parser::ParseError;
use crate::parser::parse_patch;
use crate::unified_diff_from_chunks;
use codex_utils_path_uri::PathUri;
use std::str::Utf8Error;
use tree_sitter::LanguageError;
@@ -185,7 +186,18 @@ pub async fn verify_apply_patch_args(
);
}
Hunk::DeleteFile { .. } => {
let content = match fs.read_file_text(&path, sandbox).await {
let path_uri = match PathUri::from_abs_path(&path) {
Ok(path_uri) => path_uri,
Err(e) => {
return MaybeApplyPatchVerified::CorrectnessError(
ApplyPatchError::IoError(IoError {
context: format!("Failed to read {}", path.display()),
source: e,
}),
);
}
};
let content = match fs.read_file_text(&path_uri, sandbox).await {
Ok(content) => content,
Err(e) => {
return MaybeApplyPatchVerified::CorrectnessError(
+32 -12
View File
@@ -16,6 +16,7 @@ use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::FileSystemSandboxContext;
use codex_exec_server::RemoveOptions;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri;
pub use parser::Hunk;
pub use parser::ParseError;
use parser::ParseError::*;
@@ -390,6 +391,7 @@ async fn apply_hunks_to_files(
for hunk in hunks {
let affected_path = hunk.path().to_path_buf();
let path_abs = hunk.resolve_path(cwd);
let path_uri = PathUri::from_abs_path(&path_abs)?;
match hunk {
Hunk::AddFile { contents, .. } => {
let overwritten_content =
@@ -415,7 +417,7 @@ async fn apply_hunks_to_files(
}
Hunk::DeleteFile { .. } => {
note_existing_path_delta_support(&path_abs, fs, sandbox, &mut delta.exact).await;
let deleted_content = fs.read_file_text(&path_abs, sandbox).await.ok();
let deleted_content = fs.read_file_text(&path_uri, sandbox).await.ok();
if deleted_content.is_none() {
delta.exact = false;
}
@@ -424,7 +426,7 @@ async fn apply_hunks_to_files(
.with_context(|| format!("Failed to delete file {}", path_abs.display()))?;
if let Err(error) = fs
.remove(
&path_abs,
&path_uri,
RemoveOptions {
recursive: false,
force: false,
@@ -488,7 +490,7 @@ async fn apply_hunks_to_files(
})?;
if let Err(error) = fs
.remove(
&path_abs,
&path_uri,
RemoveOptions {
recursive: false,
force: false,
@@ -521,7 +523,7 @@ async fn apply_hunks_to_files(
modified.push(affected_path);
} else {
try_write!(
fs.write_file(&path_abs, new_contents.clone().into_bytes(), sandbox)
fs.write_file(&path_uri, new_contents.clone().into_bytes(), sandbox)
.await
.with_context(|| format!(
"Failed to write file {}",
@@ -554,7 +556,8 @@ async fn ensure_not_directory(
fs: &dyn ExecutorFileSystem,
sandbox: Option<&FileSystemSandboxContext>,
) -> io::Result<()> {
let metadata = fs.get_metadata(path, sandbox).await?;
let path_uri = PathUri::from_abs_path(path)?;
let metadata = fs.get_metadata(&path_uri, sandbox).await?;
if metadata.is_directory {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
@@ -570,9 +573,12 @@ async fn remove_failure_was_side_effect_free(
fs: &dyn ExecutorFileSystem,
sandbox: Option<&FileSystemSandboxContext>,
) -> bool {
let Ok(path_uri) = PathUri::from_abs_path(path) else {
return false;
};
match expected_content {
Some(expected_content) => fs
.read_file_text(path, sandbox)
.read_file_text(&path_uri, sandbox)
.await
.is_ok_and(|content| content == expected_content),
None => false,
@@ -586,7 +592,14 @@ async fn read_optional_file_text_for_delta(
exact: &mut bool,
) -> Option<String> {
note_existing_path_delta_support(path, fs, sandbox, exact).await;
match fs.read_file_text(path, sandbox).await {
let path_uri = match PathUri::from_abs_path(path) {
Ok(path_uri) => path_uri,
Err(_) => {
*exact = false;
return None;
}
};
match fs.read_file_text(&path_uri, sandbox).await {
Ok(content) => Some(content),
Err(source) if source.kind() == io::ErrorKind::NotFound => None,
Err(_) => {
@@ -602,7 +615,11 @@ async fn note_existing_path_delta_support(
sandbox: Option<&FileSystemSandboxContext>,
exact: &mut bool,
) {
match fs.get_metadata(path, sandbox).await {
let Ok(path_uri) = PathUri::from_abs_path(path) else {
*exact = false;
return;
};
match fs.get_metadata(&path_uri, sandbox).await {
Ok(metadata) if metadata.is_file && !metadata.is_symlink => {}
Ok(_) => *exact = false,
Err(source) if source.kind() == io::ErrorKind::NotFound => {}
@@ -616,12 +633,14 @@ async fn write_file_with_missing_parent_retry(
contents: Vec<u8>,
sandbox: Option<&FileSystemSandboxContext>,
) -> anyhow::Result<()> {
match fs.write_file(path_abs, contents.clone(), sandbox).await {
let path_uri = PathUri::from_abs_path(path_abs)?;
match fs.write_file(&path_uri, contents.clone(), sandbox).await {
Ok(()) => Ok(()),
Err(err) if err.kind() == io::ErrorKind::NotFound => {
if let Some(parent_abs) = path_abs.parent() {
let parent_uri = PathUri::from_abs_path(&parent_abs)?;
fs.create_directory(
&parent_abs,
&parent_uri,
CreateDirectoryOptions { recursive: true },
sandbox,
)
@@ -633,7 +652,7 @@ async fn write_file_with_missing_parent_retry(
)
})?;
}
fs.write_file(path_abs, contents, sandbox)
fs.write_file(&path_uri, contents, sandbox)
.await
.with_context(|| format!("Failed to write file {}", path_abs.display()))?;
Ok(())
@@ -657,7 +676,8 @@ async fn derive_new_contents_from_chunks(
fs: &dyn ExecutorFileSystem,
sandbox: Option<&FileSystemSandboxContext>,
) -> std::result::Result<AppliedPatch, ApplyPatchError> {
let original_contents = fs.read_file_text(path_abs, sandbox).await.map_err(|err| {
let path_uri = PathUri::from_abs_path(path_abs)?;
let original_contents = fs.read_file_text(&path_uri, sandbox).await.map_err(|err| {
ApplyPatchError::IoError(IoError {
context: format!("Failed to read file to update {}", path_abs.display()),
source: err,
+2 -1
View File
@@ -1,4 +1,5 @@
load("//:defs.bzl", "MACOS_WEBRTC_RUSTC_LINK_FLAGS", "codex_rust_crate", "multiplatform_binaries")
load("//:defs.bzl", "MACOS_WEBRTC_RUSTC_LINK_FLAGS", "codex_rust_crate")
load("//bazel/platforms:release_binaries.bzl", "multiplatform_binaries")
codex_rust_crate(
name = "cli",
+1
View File
@@ -33,6 +33,7 @@ codex-utils-cli = { workspace = true }
codex-config = { workspace = true }
codex-core = { workspace = true }
codex-core-plugins = { workspace = true }
codex-home = { workspace = true }
codex-exec = { workspace = true }
codex-exec-server = { workspace = true }
codex-execpolicy = { workspace = true }
+23 -6
View File
@@ -1324,6 +1324,7 @@ fn stored_auth_mode(auth: &codex_login::AuthDotJson) -> &'static str {
codex_app_server_protocol::AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens",
codex_app_server_protocol::AuthMode::AgentIdentity => "agent_identity",
codex_app_server_protocol::AuthMode::PersonalAccessToken => "personal_access_token",
codex_app_server_protocol::AuthMode::BedrockApiKey => "bedrock_api_key",
}
}
@@ -1331,10 +1332,12 @@ fn stored_auth_mode_value(auth: &AuthDotJson) -> codex_app_server_protocol::Auth
if let Some(mode) = auth.auth_mode {
return mode;
}
if auth.openai_api_key.is_some() {
codex_app_server_protocol::AuthMode::ApiKey
} else if auth.personal_access_token.is_some() {
if auth.personal_access_token.is_some() {
codex_app_server_protocol::AuthMode::PersonalAccessToken
} else if auth.bedrock_api_key.is_some() {
codex_app_server_protocol::AuthMode::BedrockApiKey
} else if auth.openai_api_key.is_some() {
codex_app_server_protocol::AuthMode::ApiKey
} else {
codex_app_server_protocol::AuthMode::Chatgpt
}
@@ -1407,6 +1410,11 @@ fn stored_auth_issues(
issues.push("personal access token auth is missing a personal access token");
}
}
codex_app_server_protocol::AuthMode::BedrockApiKey => {
if auth.bedrock_api_key.is_none() {
issues.push("Bedrock API key auth is missing a Bedrock API key");
}
}
}
issues
}
@@ -2141,8 +2149,9 @@ async fn state_check(config: &Config) -> DoctorCheck {
};
let mut check = DoctorCheck::new("state.paths", "state", status, summary).details(details);
if status == CheckStatus::Fail {
check = check
.remediation("Back up CODEX_HOME, then remove or repair the affected SQLite database.");
check = check.remediation(
"Move the damaged SQLite database aside, then restart the interactive CLI or app server so it can rebuild that runtime database from saved data. Other entry points may not rebuild automatically.",
);
}
check
}
@@ -2436,6 +2445,7 @@ fn auth_mode_name(auth: &CodexAuth) -> &'static str {
codex_app_server_protocol::AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens",
codex_app_server_protocol::AuthMode::AgentIdentity => "agent_identity",
codex_app_server_protocol::AuthMode::PersonalAccessToken => "personal_access_token",
codex_app_server_protocol::AuthMode::BedrockApiKey => "bedrock_api_key",
}
}
@@ -2565,7 +2575,10 @@ fn provider_auth_reachability_mode_from_auth(
return ProviderAuthReachabilityMode::Chatgpt;
}
match stored_auth.map(stored_auth_mode_value) {
Some(codex_app_server_protocol::AuthMode::ApiKey) => ProviderAuthReachabilityMode::ApiKey,
Some(
codex_app_server_protocol::AuthMode::ApiKey
| codex_app_server_protocol::AuthMode::BedrockApiKey,
) => ProviderAuthReachabilityMode::ApiKey,
Some(
codex_app_server_protocol::AuthMode::Chatgpt
| codex_app_server_protocol::AuthMode::ChatgptAuthTokens
@@ -3470,6 +3483,7 @@ mod tests {
last_refresh: None,
agent_identity: None,
personal_access_token: None,
bedrock_api_key: None,
};
assert_eq!(
@@ -3488,6 +3502,7 @@ mod tests {
last_refresh: None,
agent_identity: None,
personal_access_token: None,
bedrock_api_key: None,
};
assert_eq!(
@@ -3508,6 +3523,7 @@ mod tests {
last_refresh: None,
agent_identity: None,
personal_access_token: Some("at-test".to_string()),
bedrock_api_key: None,
};
assert_eq!(stored_auth_mode(&auth), "personal_access_token");
@@ -3530,6 +3546,7 @@ mod tests {
last_refresh: None,
agent_identity: None,
personal_access_token: None,
bedrock_api_key: None,
};
assert_eq!(
+4
View File
@@ -430,6 +430,10 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
eprintln!("Logged in using personal access token");
std::process::exit(0);
}
AuthMode::BedrockApiKey => {
eprintln!("Logged in using Amazon Bedrock API key");
std::process::exit(0);
}
},
Ok(None) => {
eprintln!("Not logged in");
+135 -22
View File
@@ -37,10 +37,12 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_cli::CliConfigOverrides;
use codex_utils_cli::ProfileV2Name;
use codex_utils_cli::SharedCliOptions;
use codex_utils_cli::resume_hint;
use owo_colors::OwoColorize;
use std::collections::HashSet;
use std::io::IsTerminal;
use std::io::Write;
use std::path::PathBuf;
use std::sync::Arc;
use supports_color::Stream;
#[cfg(any(target_os = "macos", target_os = "windows"))]
@@ -76,6 +78,7 @@ use codex_features::FEATURES;
use codex_features::Feature;
use codex_features::Stage;
use codex_features::is_known_feature_key;
use codex_home::CodexHomeUserInstructionsProvider;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::read_codex_access_token_from_env;
@@ -181,6 +184,9 @@ enum Subcommand {
/// Archive a saved session by id or session name.
Archive(SessionArchiveCommand),
/// Permanently delete a saved session by id or session name.
Delete(DeleteCommand),
/// Unarchive a saved session by id or session name.
Unarchive(SessionArchiveCommand),
@@ -355,6 +361,16 @@ struct SessionArchiveConfigOverrides {
config_overrides: CliConfigOverrides,
}
#[derive(Debug, Args)]
struct DeleteCommand {
#[clap(flatten)]
session: SessionArchiveCommand,
/// Delete without prompting. SESSION must be a UUID.
#[arg(long, default_value_t = false)]
force: bool,
}
#[derive(Debug, Parser)]
struct ForkCommand {
/// Conversation/session id (UUID). When provided, forks this session.
@@ -686,10 +702,11 @@ fn parse_socket_path(raw: &str) -> Result<AbsolutePathBuf, String> {
}
fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<String> {
let is_fatal = matches!(&exit_info.exit_reason, ExitReason::Fatal(_));
let AppExitInfo {
token_usage,
thread_id: conversation_id,
thread_name,
resume_hint,
..
} = exit_info;
@@ -698,13 +715,15 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
lines.push(token_usage.to_string());
}
if let Some(resume_cmd) = resume_hint(thread_name.as_deref(), conversation_id) {
if let Some(resume_cmd) = resume_hint {
let command = if color_enabled {
resume_cmd.cyan().to_string()
} else {
resume_cmd
};
lines.push(format!("To continue this session, run {command}"));
} else if is_fatal && let Some(conversation_id) = conversation_id {
lines.push(format!("Session ID: {conversation_id}"));
}
lines
@@ -712,19 +731,23 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec<Stri
/// Handle the app exit and print the results. Optionally run the update action.
fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> {
match exit_info.exit_reason {
let is_fatal = match &exit_info.exit_reason {
ExitReason::Fatal(message) => {
eprintln!("ERROR: {message}");
std::process::exit(1);
true
}
ExitReason::UserRequested => { /* normal exit */ }
}
ExitReason::UserRequested => false,
};
let update_action = exit_info.update_action;
let color_enabled = supports_color::on(Stream::Stdout).is_some();
for line in format_exit_messages(exit_info, color_enabled) {
println!("{line}");
}
if is_fatal {
std::io::stdout().flush()?;
std::process::exit(1);
}
if let Some(action) = update_action {
run_update_action(action)?;
}
@@ -830,6 +853,17 @@ async fn run_session_archive_cli_command(
.map_err(|err| anyhow::anyhow!("{err}"))
}
fn delete_action(target: &str, force: bool) -> anyhow::Result<codex_tui::SessionArchiveAction> {
if force && codex_protocol::ThreadId::from_string(target).is_err() {
anyhow::bail!("--force requires a session UUID; names must be confirmed interactively");
}
let confirmation = match force {
true => codex_tui::DeleteConfirmation::Skip,
false => codex_tui::DeleteConfirmation::Prompt,
};
Ok(codex_tui::SessionArchiveAction::Delete(confirmation))
}
async fn run_debug_app_server_command(cmd: DebugAppServerCommand) -> anyhow::Result<()> {
match cmd.subcommand {
DebugAppServerSubcommand::SendMessageV2(cmd) => {
@@ -1244,6 +1278,20 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
.await?;
println!("{output}");
}
Some(Subcommand::Delete(DeleteCommand { session, force })) => {
let action = delete_action(&session.target, force)?;
let output = run_session_archive_cli_command(
action,
session,
interactive,
root_config_overrides.clone(),
root_remote.clone(),
root_remote_auth_token_env.clone(),
arg0_paths.clone(),
)
.await?;
println!("{output}");
}
Some(Subcommand::Unarchive(cmd)) => {
let output = run_session_archive_cli_command(
codex_tui::SessionArchiveAction::Unarchive,
@@ -1608,6 +1656,7 @@ fn profile_v2_for_subcommand<'a>(
| Subcommand::Review(_)
| Subcommand::Resume(_)
| Subcommand::Archive(_)
| Subcommand::Delete(_)
| Subcommand::Unarchive(_)
| Subcommand::Fork(_)
| Subcommand::Mcp(_)
@@ -1616,7 +1665,7 @@ fn profile_v2_for_subcommand<'a>(
subcommand: DebugSubcommand::PromptInput(_),
}) => Ok(Some(profile_v2)),
_ => anyhow::bail!(
"--profile only applies to runtime commands and `codex mcp`: `codex`, `codex exec`, `codex review`, `codex resume`, `codex archive`, `codex unarchive`, `codex fork`, `codex mcp`, `codex sandbox`, and `codex debug prompt-input`."
"--profile only applies to runtime commands and `codex mcp`: `codex`, `codex exec`, `codex review`, `codex resume`, `codex archive`, `codex delete`, `codex unarchive`, `codex fork`, `codex mcp`, `codex sandbox`, and `codex debug prompt-input`."
),
}
}
@@ -1910,7 +1959,16 @@ async fn run_debug_prompt_input_command(
});
}
let prompt_input = codex_core::build_prompt_input(config, input, /*state_db*/ None).await?;
let user_instructions_provider = Arc::new(CodexHomeUserInstructionsProvider::new(
config.codex_home.clone(),
));
let prompt_input = codex_core::build_prompt_input(
config,
input,
/*state_db*/ None,
user_instructions_provider,
)
.await?;
println!("{}", serde_json::to_string_pretty(&prompt_input)?);
Ok(())
@@ -2041,6 +2099,7 @@ fn unsupported_subcommand_name_for_strict_config(
| Some(Subcommand::ExecServer(_))
| Some(Subcommand::Resume(_))
| Some(Subcommand::Archive(_))
| Some(Subcommand::Delete(_))
| Some(Subcommand::Unarchive(_))
| Some(Subcommand::Fork(_))
| Some(Subcommand::Doctor(_)) => None,
@@ -2204,7 +2263,7 @@ async fn run_interactive_tui(
remote_endpoint.clone(),
)
};
let mut attempted_repair = false;
let mut attempted_backups = HashSet::new();
loop {
let err = match start_tui().await {
Ok(exit_info) => return Ok(exit_info),
@@ -2217,25 +2276,25 @@ async fn run_interactive_tui(
local_state_db::print_locked_guidance(startup_error);
return Ok(AppExitInfo::fatal(startup_error.to_string()));
}
if attempted_repair {
if !local_state_db::is_auto_backup_recoverable(startup_error) {
local_state_db::print_diagnostic_guidance(startup_error);
return Ok(AppExitInfo::fatal(startup_error.to_string()));
}
if !local_state_db::confirm_repair(startup_error)? {
if !attempted_backups.insert(startup_error.database_path().to_path_buf()) {
local_state_db::print_diagnostic_guidance(startup_error);
return Ok(AppExitInfo::fatal(startup_error.to_string()));
}
match local_state_db::repair_files(startup_error).await {
Ok(backups) => local_state_db::print_repair_backups(&backups),
Err(repair_err) => {
local_state_db::print_auto_backup_start(startup_error);
match local_state_db::backup_files_for_fresh_start(startup_error).await {
Ok(backups) => local_state_db::confirm_fresh_start_rebuild(startup_error, &backups)?,
Err(backup_err) => {
local_state_db::print_diagnostic_guidance(startup_error);
return Ok(AppExitInfo::fatal(format!(
"failed to repair Codex local data automatically: {repair_err}"
"failed to move damaged Codex local database files into a backup folder automatically: {backup_err}"
)));
}
}
attempted_repair = true;
}
}
@@ -2615,6 +2674,14 @@ mod tests {
);
}
#[test]
fn import_remains_an_interactive_prompt() {
let cli = MultitoolCli::try_parse_from(["codex", "import"]).expect("parse");
assert!(cli.subcommand.is_none());
assert_eq!(cli.interactive.prompt.as_deref(), Some("import"));
}
#[test]
fn profile_v2_rejects_non_plain_names_at_parse_time() {
assert!(
@@ -2875,6 +2942,17 @@ mod tests {
assert!(interactive.bypass_hook_trust);
}
#[test]
fn delete_force_requires_uuid() {
assert!(delete_action("123e4567-e89b-12d3-a456-426614174000", true).is_ok());
let err = delete_action("my-thread", true).expect_err("name should require prompt");
assert_eq!(
err.to_string(),
"--force requires a session UUID; names must be confirmed interactively"
);
}
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
#[test]
fn sandbox_parses_permissions_profile() {
@@ -2993,12 +3071,13 @@ mod tests {
total_tokens: 2,
..Default::default()
};
let thread_id = conversation_id
.map(ThreadId::from_string)
.map(Result::unwrap);
AppExitInfo {
token_usage,
thread_id: conversation_id
.map(ThreadId::from_string)
.map(Result::unwrap),
thread_name: thread_name.map(str::to_string),
thread_id,
resume_hint: codex_utils_cli::resume_hint(thread_name, thread_id),
update_action: None,
exit_reason: ExitReason::UserRequested,
}
@@ -3009,7 +3088,7 @@ mod tests {
let exit_info = AppExitInfo {
token_usage: TokenUsage::default(),
thread_id: None,
thread_name: None,
resume_hint: None,
update_action: None,
exit_reason: ExitReason::UserRequested,
};
@@ -3017,6 +3096,40 @@ mod tests {
assert!(lines.is_empty());
}
#[test]
fn format_exit_messages_includes_session_id_for_fatal_exit_without_resume_hint() {
let exit_info = AppExitInfo {
token_usage: TokenUsage::default(),
thread_id: Some(ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").unwrap()),
resume_hint: None,
update_action: None,
exit_reason: ExitReason::Fatal("boom".to_string()),
};
let lines = format_exit_messages(exit_info, /*color_enabled*/ false);
assert_eq!(
lines,
vec!["Session ID: 123e4567-e89b-12d3-a456-426614174000".to_string()]
);
}
#[test]
fn format_exit_messages_includes_resume_hint_for_fatal_exit() {
let mut exit_info = sample_exit_info(
Some("123e4567-e89b-12d3-a456-426614174000"),
/*thread_name*/ None,
);
exit_info.exit_reason = ExitReason::Fatal("boom".to_string());
let lines = format_exit_messages(exit_info, /*color_enabled*/ false);
assert_eq!(
lines,
vec![
"Token usage: total=2 input=0 output=2".to_string(),
"To continue this session, run codex resume 123e4567-e89b-12d3-a456-426614174000"
.to_string(),
]
);
}
#[test]
fn format_exit_messages_includes_resume_hint_without_color() {
let exit_info = sample_exit_info(
+2 -2
View File
@@ -204,7 +204,7 @@ pub async fn run_plugin_list(
..
} = load_plugin_command_context(overrides).await?;
let outcome = manager
.list_marketplaces_for_config(&plugins_input, &[])
.list_marketplaces_for_config(&plugins_input, &[], /*include_openai_curated*/ true)
.context("failed to list marketplace plugins")?;
ensure_configured_marketplace_snapshots_loaded(
codex_home.as_path(),
@@ -609,7 +609,7 @@ fn find_marketplace_for_plugin(
plugin_name: &str,
) -> Result<ConfiguredMarketplace> {
let outcome = manager
.list_marketplaces_for_config(plugins_input, &[])
.list_marketplaces_for_config(plugins_input, &[], /*include_openai_curated*/ true)
.context("failed to list marketplace plugins")?;
ensure_configured_marketplace_snapshots_loaded(
codex_home,
+84 -110
View File
@@ -1,10 +1,12 @@
//! CLI recovery for local state database startup failures.
//! CLI handling for local state database startup failures.
//!
//! This keeps user-facing repair and lock-contention handling out of the main
//! This keeps user-facing backup and lock-contention handling out of the main
//! CLI dispatch path while preserving the TUI startup error as the boundary type.
use codex_state::RuntimeDbBackup;
use codex_tui::LocalStateDbStartupError;
use std::path::PathBuf;
use std::io::IsTerminal;
use std::path::Path;
pub(crate) fn startup_error(err: &std::io::Error) -> Option<&LocalStateDbStartupError> {
err.get_ref()
@@ -12,66 +14,60 @@ pub(crate) fn startup_error(err: &std::io::Error) -> Option<&LocalStateDbStartup
}
pub(crate) fn is_locked(detail: &str) -> bool {
let detail = detail.to_ascii_lowercase();
detail.contains("database is locked") || detail.contains("database is busy")
codex_state::sqlite_error_detail_is_lock(detail)
}
pub(crate) fn confirm_repair(startup_error: &LocalStateDbStartupError) -> std::io::Result<bool> {
pub(crate) fn is_corruption(detail: &str) -> bool {
codex_state::sqlite_error_detail_is_corruption(detail)
}
pub(crate) fn is_auto_backup_recoverable(startup_error: &LocalStateDbStartupError) -> bool {
is_corruption(startup_error.detail()) || sqlite_home_is_blocking_file(startup_error)
}
fn sqlite_home_is_blocking_file(startup_error: &LocalStateDbStartupError) -> bool {
startup_error
.database_path()
.parent()
.and_then(|path| std::fs::metadata(path).ok())
.is_some_and(|metadata| metadata.is_file())
}
pub(crate) fn print_auto_backup_start(startup_error: &LocalStateDbStartupError) {
eprintln!("Codex couldn't start because its local database appears to be damaged.");
eprintln!("Codex can try a safe repair by backing up those files and rebuilding them.");
eprintln!("Moving the damaged local database aside so Codex can rebuild it from saved data.");
print_technical_details(startup_error);
crate::confirm("Repair Codex local data now? [y/N]: ")
}
pub(crate) async fn repair_files(
pub(crate) async fn backup_files_for_fresh_start(
startup_error: &LocalStateDbStartupError,
) -> std::io::Result<Vec<PathBuf>> {
let state_db_path = startup_error.state_db_path();
let sqlite_home = state_db_path.parent().ok_or_else(|| {
std::io::Error::other("state database path does not have a parent directory")
})?;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| duration.as_secs());
let repair_suffix = format!("codex-repair-{timestamp}");
let mut backups = Vec::new();
match tokio::fs::metadata(sqlite_home).await {
Ok(metadata) if metadata.is_dir() => {}
Ok(_) => {
backups.push(backup_path(sqlite_home, &repair_suffix).await?);
tokio::fs::create_dir_all(sqlite_home).await?;
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
tokio::fs::create_dir_all(sqlite_home).await?;
}
Err(err) => return Err(err),
}
for path in codex_state::runtime_db_paths(sqlite_home)
.into_iter()
.flat_map(|db| sqlite_paths(db.path.as_path()))
{
if tokio::fs::try_exists(path.as_path()).await? {
backups.push(backup_path(path.as_path(), &repair_suffix).await?);
}
}
if backups.is_empty() {
return Err(std::io::Error::other(
"no repairable Codex local data files were found",
));
}
Ok(backups)
) -> std::io::Result<Vec<RuntimeDbBackup>> {
codex_state::backup_runtime_db_for_fresh_start(startup_error.database_path()).await
}
pub(crate) fn print_repair_backups(backups: &[PathBuf]) {
eprintln!("Backed up Codex local data before repair:");
for backup in backups {
eprintln!(" {}", backup.display());
pub(crate) fn confirm_fresh_start_rebuild(
startup_error: &LocalStateDbStartupError,
backups: &[RuntimeDbBackup],
) -> std::io::Result<()> {
eprintln!("Codex rebuilt its local database.");
eprintln!(
"Codex detected a damaged local database, moved it into a backup folder, and will continue startup with a fresh database."
);
eprintln!("Database path: {}", startup_error.database_path().display());
if let Some(backup_folder) = backup_folder(backups) {
eprintln!("Backup folder: {}", backup_folder.display());
} else {
eprintln!("Backup folder: unavailable");
}
eprintln!("Retrying startup with rebuilt local data...");
if std::io::stdin().is_terminal() && std::io::stderr().is_terminal() {
eprintln!("Press Enter to continue.");
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
} else {
eprintln!("Continuing startup with a fresh local database...");
}
Ok(())
}
pub(crate) fn print_diagnostic_guidance(startup_error: &LocalStateDbStartupError) {
@@ -87,79 +83,50 @@ pub(crate) fn print_locked_guidance(startup_error: &LocalStateDbStartupError) {
print_technical_details(startup_error);
}
fn sqlite_paths(db_path: &std::path::Path) -> Vec<PathBuf> {
let mut wal_path = db_path.as_os_str().to_os_string();
wal_path.push("-wal");
let mut shm_path = db_path.as_os_str().to_os_string();
shm_path.push("-shm");
vec![
db_path.to_path_buf(),
PathBuf::from(wal_path),
PathBuf::from(shm_path),
]
}
async fn backup_path(path: &std::path::Path, repair_suffix: &str) -> std::io::Result<PathBuf> {
let file_name = path.file_name().ok_or_else(|| {
std::io::Error::other(format!(
"cannot create a repair backup name for {}",
path.display()
))
})?;
let mut sequence = 0;
loop {
let mut backup_name = file_name.to_os_string();
backup_name.push(format!(".{repair_suffix}.{sequence}.bak"));
let backup_path = path.with_file_name(backup_name);
if !tokio::fs::try_exists(backup_path.as_path()).await? {
tokio::fs::rename(path, backup_path.as_path()).await?;
return Ok(backup_path);
}
sequence += 1;
}
}
fn print_technical_details(startup_error: &LocalStateDbStartupError) {
eprintln!("Technical details:");
eprintln!(" Location: {}", startup_error.state_db_path().display());
eprintln!(" Location: {}", startup_error.database_path().display());
eprintln!(" Cause: {}", startup_error.detail());
}
fn backup_folder(backups: &[RuntimeDbBackup]) -> Option<&Path> {
backups.first()?.backup_path.parent()
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
use tempfile::TempDir;
#[tokio::test]
async fn repair_backs_up_owned_database_files() -> std::io::Result<()> {
async fn backup_backs_up_only_failed_database_file() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let state_path = codex_state::state_db_path(temp_dir.path());
let logs_path = codex_state::logs_db_path(temp_dir.path());
let goals_path = codex_state::goals_db_path(temp_dir.path());
let state_sidecars = sqlite_paths(state_path.as_path());
let failed_db_path = codex_state::logs_db_path(temp_dir.path());
tokio::fs::write(state_path.as_path(), b"state").await?;
tokio::fs::write(state_sidecars[1].as_path(), b"state-wal").await?;
tokio::fs::write(logs_path.as_path(), b"logs").await?;
tokio::fs::write(goals_path.as_path(), b"goals").await?;
tokio::fs::write(failed_db_path.as_path(), b"logs").await?;
let startup_error =
LocalStateDbStartupError::new(state_path.clone(), "corrupt".to_string());
let backups = repair_files(&startup_error).await?;
LocalStateDbStartupError::new(failed_db_path.clone(), "corrupt".to_string());
let backups = backup_files_for_fresh_start(&startup_error).await?;
assert_eq!(backups.len(), 4);
assert!(!tokio::fs::try_exists(state_path.as_path()).await?);
assert!(!tokio::fs::try_exists(state_sidecars[1].as_path()).await?);
assert!(!tokio::fs::try_exists(logs_path.as_path()).await?);
assert!(!tokio::fs::try_exists(goals_path.as_path()).await?);
for backup in backups {
assert!(tokio::fs::try_exists(backup.as_path()).await?);
}
assert_eq!(
backups
.iter()
.map(|backup| &backup.original_path)
.collect::<Vec<_>>(),
vec![&failed_db_path]
);
assert!(!tokio::fs::try_exists(failed_db_path.as_path()).await?);
assert!(tokio::fs::try_exists(state_path.as_path()).await?);
assert!(tokio::fs::try_exists(backups[0].backup_path.as_path()).await?);
Ok(())
}
#[tokio::test]
async fn repair_replaces_blocking_sqlite_home_file() -> std::io::Result<()> {
async fn backup_replaces_blocking_sqlite_home_file() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
let sqlite_home = temp_dir.path().join("sqlite-home");
tokio::fs::write(sqlite_home.as_path(), b"not-a-directory").await?;
@@ -168,18 +135,25 @@ mod tests {
"File exists".to_string(),
);
let backups = repair_files(&startup_error).await?;
assert!(is_auto_backup_recoverable(&startup_error));
let backups = backup_files_for_fresh_start(&startup_error).await?;
assert_eq!(backups.len(), 1);
assert!(tokio::fs::metadata(sqlite_home.as_path()).await?.is_dir());
assert!(tokio::fs::try_exists(backups[0].as_path()).await?);
assert!(tokio::fs::try_exists(backups[0].backup_path.as_path()).await?);
Ok(())
}
#[test]
fn lock_failures_skip_repair() {
assert!(is_locked("database is locked"));
assert!(is_locked("database is busy"));
assert!(!is_locked("database disk image is malformed"));
fn backup_folder_uses_parent_of_first_backup_path() {
let backups = vec![RuntimeDbBackup {
original_path: PathBuf::from("/tmp/state_5.sqlite"),
backup_path: PathBuf::from("/tmp/db-backups/sqlite-1-0/state_5.sqlite"),
}];
assert_eq!(
backup_folder(&backups),
Some(Path::new("/tmp/db-backups/sqlite-1-0"))
);
}
}
+17
View File
@@ -0,0 +1,17 @@
use predicates::prelude::*;
#[test]
fn missing_session_fails_before_delete_confirmation() -> anyhow::Result<()> {
let codex_home = tempfile::tempdir()?;
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
cmd.env("CODEX_HOME", codex_home.path())
.args(["delete", "123e4567-e89b-12d3-a456-426614174000"]);
cmd.assert()
.failure()
.stderr(predicate::str::contains(
"No active or archived session found matching",
))
.stderr(predicate::str::contains("cannot confirm").not());
Ok(())
}
+1 -1
View File
@@ -22,7 +22,7 @@ use tokio::fs;
const CLOUD_CONFIG_BUNDLE_CACHE_VERSION: u32 = 1;
pub(super) const CLOUD_CONFIG_BUNDLE_CACHE_FILENAME: &str = "cloud-config-bundle-cache.json";
const CLOUD_CONFIG_BUNDLE_CACHE_TTL: Duration = Duration::from_secs(30 * 60);
const CLOUD_CONFIG_BUNDLE_CACHE_TTL: Duration = Duration::from_secs(60 * 60);
const CLOUD_CONFIG_BUNDLE_CACHE_WRITE_HMAC_KEY: &[u8] =
b"codex-cloud-config-bundle-cache-v1-6160ae70-bcfd-4ca8-a99b-40f73b3b072e";
const CLOUD_CONFIG_BUNDLE_CACHE_READ_HMAC_KEYS: &[&[u8]] =
+1 -1
View File
@@ -81,7 +81,7 @@ async fn save_writes_signed_payload_and_loads_for_matching_identity() {
.expect("parse cache");
assert!(
cache_file.signed_payload.expires_at
<= cache_file.signed_payload.cached_at + ChronoDuration::minutes(30)
<= cache_file.signed_payload.cached_at + ChronoDuration::minutes(60)
);
assert!(cache_file.signed_payload.expires_at > cache_file.signed_payload.cached_at);
assert_eq!(
+1 -1
View File
@@ -32,7 +32,7 @@ use tokio::time::timeout;
pub(crate) const CLOUD_CONFIG_BUNDLE_TIMEOUT: Duration = Duration::from_secs(15);
const CLOUD_CONFIG_BUNDLE_MAX_ATTEMPTS: usize = 5;
const CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60);
const CLOUD_CONFIG_BUNDLE_CACHE_REFRESH_INTERVAL: Duration = Duration::from_secs(15 * 60);
const CLOUD_CONFIG_BUNDLE_LOAD_FAILED_MESSAGE: &str =
"Failed to load cloud config bundle (workspace-managed policies).";
const CLOUD_CONFIG_BUNDLE_AUTH_RECOVERY_FAILED_MESSAGE: &str = concat!(
@@ -291,6 +291,18 @@ impl RealtimeWebsocketWriter {
.await
}
pub async fn send_conversation_handoff_append(
&self,
handoff_id: String,
output_text: String,
) -> Result<(), ApiError> {
self.send_json(&RealtimeOutboundMessage::ConversationHandoffAppend {
handoff_id,
output_text,
})
.await
}
pub async fn send_conversation_function_call_output(
&self,
call_id: String,
@@ -93,6 +93,7 @@ async fn models_client_hits_models_endpoint() {
context_window: Some(272_000),
max_context_window: None,
auto_compact_token_limit: None,
comp_hash: None,
effective_context_window_percent: 95,
experimental_supported_tools: Vec::new(),
input_modalities: default_input_modalities(),
+6
View File
@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "codex-home",
crate_name = "codex_home",
)
+21
View File
@@ -0,0 +1,21 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-home"
version.workspace = true
[lib]
doctest = false
[lints]
workspace = true
[dependencies]
codex-extension-api = { workspace = true }
codex-utils-absolute-path = { workspace = true }
tokio = { workspace = true, features = ["fs"] }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt"] }
@@ -0,0 +1,83 @@
use std::io;
use codex_extension_api::LoadUserInstructionsFuture;
use codex_extension_api::LoadedUserInstructions;
use codex_extension_api::UserInstructions;
use codex_extension_api::UserInstructionsProvider;
use codex_utils_absolute_path::AbsolutePathBuf;
const DEFAULT_AGENTS_MD_FILENAME: &str = "AGENTS.md";
const LOCAL_AGENTS_MD_FILENAME: &str = "AGENTS.override.md";
/// Loads user instructions from a Codex home directory.
#[derive(Clone, Debug)]
pub struct CodexHomeUserInstructionsProvider {
codex_home: AbsolutePathBuf,
}
impl CodexHomeUserInstructionsProvider {
/// Creates a provider rooted at the supplied absolute Codex home directory.
pub fn new(codex_home: AbsolutePathBuf) -> Self {
Self { codex_home }
}
async fn load_from_codex_home(&self) -> LoadedUserInstructions {
let mut warnings = Vec::new();
for candidate in [LOCAL_AGENTS_MD_FILENAME, DEFAULT_AGENTS_MD_FILENAME] {
let path = self.codex_home.join(candidate);
match tokio::fs::metadata(path.as_path()).await {
Ok(metadata) if !metadata.is_file() => continue,
Ok(_) => {}
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
Err(err) => {
warnings.push(format!(
"Failed to read global AGENTS.md instructions from `{}`: {err}",
path.display()
));
continue;
}
}
let data = match tokio::fs::read(path.as_path()).await {
Ok(data) => data,
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
Err(err) => {
warnings.push(format!(
"Failed to read global AGENTS.md instructions from `{}`: {err}",
path.display()
));
continue;
}
};
if let Err(err) = std::str::from_utf8(&data) {
warnings.push(format!(
"Global AGENTS.md instructions from `{}` contain invalid UTF-8: {err}. Invalid byte sequences were replaced.",
path.display()
));
}
let contents = String::from_utf8_lossy(&data);
let trimmed = contents.trim();
if !trimmed.is_empty() {
return LoadedUserInstructions {
instructions: Some(UserInstructions {
text: trimmed.to_string(),
source: path,
}),
warnings,
};
}
}
LoadedUserInstructions {
instructions: None,
warnings,
}
}
}
impl UserInstructionsProvider for CodexHomeUserInstructionsProvider {
fn load_user_instructions(&self) -> LoadUserInstructionsFuture<'_> {
Box::pin(self.load_from_codex_home())
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,152 @@
use std::fs;
use std::path::Path;
use codex_extension_api::LoadedUserInstructions;
use codex_extension_api::UserInstructions;
use codex_extension_api::UserInstructionsProvider;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use super::CodexHomeUserInstructionsProvider;
use super::DEFAULT_AGENTS_MD_FILENAME;
use super::LOCAL_AGENTS_MD_FILENAME;
fn provider(home: &TempDir) -> CodexHomeUserInstructionsProvider {
CodexHomeUserInstructionsProvider::new(
AbsolutePathBuf::try_from(home.path().to_path_buf()).expect("absolute temp dir"),
)
}
fn expected(
home: &TempDir,
filename: &str,
text: &str,
warnings: Vec<String>,
) -> LoadedUserInstructions {
LoadedUserInstructions {
instructions: Some(UserInstructions {
text: text.to_string(),
source: AbsolutePathBuf::try_from(home.path().join(filename))
.expect("absolute source path"),
}),
warnings,
}
}
#[cfg(unix)]
fn create_symlink_loop(path: &Path) {
std::os::unix::fs::symlink(
path.file_name().expect("override path should have a name"),
path,
)
.expect("create symlink loop");
}
#[cfg(windows)]
fn create_symlink_loop(path: &Path) {
std::os::windows::fs::symlink_file(
path.file_name().expect("override path should have a name"),
path,
)
.expect("create symlink loop");
}
#[tokio::test]
async fn missing_files_return_no_instructions() {
let home = TempDir::new().expect("temp dir");
assert_eq!(
provider(&home).load_user_instructions().await,
LoadedUserInstructions::default()
);
}
#[tokio::test]
async fn override_takes_precedence_over_default() {
let home = TempDir::new().expect("temp dir");
fs::write(home.path().join(DEFAULT_AGENTS_MD_FILENAME), "default").expect("write default");
fs::write(home.path().join(LOCAL_AGENTS_MD_FILENAME), "override").expect("write override");
assert_eq!(
provider(&home).load_user_instructions().await,
expected(&home, LOCAL_AGENTS_MD_FILENAME, "override", Vec::new())
);
}
#[tokio::test]
async fn empty_override_falls_back_to_trimmed_default() {
let home = TempDir::new().expect("temp dir");
fs::write(home.path().join(LOCAL_AGENTS_MD_FILENAME), " \n\t").expect("write override");
fs::write(
home.path().join(DEFAULT_AGENTS_MD_FILENAME),
"\n default instructions \n",
)
.expect("write default");
assert_eq!(
provider(&home).load_user_instructions().await,
expected(
&home,
DEFAULT_AGENTS_MD_FILENAME,
"default instructions",
Vec::new()
)
);
}
#[tokio::test]
async fn directory_override_falls_back_to_default() {
let home = TempDir::new().expect("temp dir");
fs::create_dir(home.path().join(LOCAL_AGENTS_MD_FILENAME)).expect("create override directory");
fs::write(home.path().join(DEFAULT_AGENTS_MD_FILENAME), "default").expect("write default");
assert_eq!(
provider(&home).load_user_instructions().await,
expected(&home, DEFAULT_AGENTS_MD_FILENAME, "default", Vec::new())
);
}
#[tokio::test]
async fn recoverable_override_read_error_warns_and_falls_back_to_default() {
let home = TempDir::new().expect("temp dir");
let override_path = home.path().join(LOCAL_AGENTS_MD_FILENAME);
create_symlink_loop(&override_path);
fs::write(home.path().join(DEFAULT_AGENTS_MD_FILENAME), "default").expect("write default");
let read_error = fs::read(&override_path).expect_err("symlink loop should not be readable");
let warning = format!(
"Failed to read global AGENTS.md instructions from `{}`: {read_error}",
override_path.display()
);
assert_eq!(
provider(&home).load_user_instructions().await,
expected(&home, DEFAULT_AGENTS_MD_FILENAME, "default", vec![warning])
);
}
#[tokio::test]
async fn invalid_utf8_is_lossy_and_warned() {
let home = TempDir::new().expect("temp dir");
let path = home.path().join(DEFAULT_AGENTS_MD_FILENAME);
let mut invalid_utf8 = b"global".to_vec();
invalid_utf8.push(0xff);
invalid_utf8.extend_from_slice(b" doc");
fs::write(&path, &invalid_utf8).expect("write invalid utf-8");
let outcome = provider(&home).load_user_instructions().await;
let utf8_error = std::str::from_utf8(&invalid_utf8).expect_err("invalid utf-8");
let warning = format!(
"Global AGENTS.md instructions from `{}` contain invalid UTF-8: {utf8_error}. Invalid byte sequences were replaced.",
path.display(),
);
assert_eq!(
outcome,
expected(
&home,
DEFAULT_AGENTS_MD_FILENAME,
"global\u{fffd} doc",
vec![warning]
)
);
}
+3
View File
@@ -0,0 +1,3 @@
mod instructions;
pub use instructions::CodexHomeUserInstructionsProvider;
+1
View File
@@ -14,6 +14,7 @@ workspace = true
[dependencies]
anyhow = { workspace = true }
arc-swap = { workspace = true }
async-channel = { workspace = true }
codex-async-utils = { workspace = true }
codex-api = { workspace = true }
+333
View File
@@ -0,0 +1,333 @@
use std::cmp::Reverse;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashMap;
use codex_config::McpServerConfig;
/// The component that declared an MCP server registration.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum McpServerSource {
Plugin { plugin_id: String },
Config,
Compatibility { id: String },
Extension { id: String },
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum RegistrationPrecedence {
Plugin(Reverse<usize>),
Config,
Compatibility,
Extension(usize),
}
impl RegistrationPrecedence {
fn tier(self) -> u8 {
match self {
Self::Plugin(_) => 0,
Self::Config => 1,
Self::Compatibility => 2,
Self::Extension(_) => 3,
}
}
}
/// One named MCP server declaration before source resolution.
#[derive(Clone, Debug, PartialEq)]
pub struct McpServerRegistration {
name: String,
source: McpServerSource,
config: McpServerConfig,
precedence: RegistrationPrecedence,
}
impl McpServerRegistration {
pub fn from_config(name: String, config: McpServerConfig) -> Self {
Self::new(
name,
McpServerSource::Config,
config,
RegistrationPrecedence::Config,
)
}
pub fn from_plugin(
name: String,
plugin_id: String,
plugin_order: usize,
config: McpServerConfig,
) -> Self {
Self::new(
name,
McpServerSource::Plugin { plugin_id },
config,
RegistrationPrecedence::Plugin(Reverse(plugin_order)),
)
}
pub fn from_compatibility(
name: String,
id: impl Into<String>,
config: McpServerConfig,
) -> Self {
Self::new(
name,
McpServerSource::Compatibility { id: id.into() },
config,
RegistrationPrecedence::Compatibility,
)
}
pub fn from_extension(
name: String,
id: impl Into<String>,
contribution_order: usize,
config: McpServerConfig,
) -> Self {
Self::new(
name,
McpServerSource::Extension { id: id.into() },
config,
RegistrationPrecedence::Extension(contribution_order),
)
}
fn new(
name: String,
source: McpServerSource,
config: McpServerConfig,
precedence: RegistrationPrecedence,
) -> Self {
Self {
name,
source,
config,
precedence,
}
}
}
/// One side of an MCP server conflict, including whether it registers or
/// removes the server.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum McpServerConflictAction {
Register(McpServerSource),
Remove(McpServerSource),
}
/// A same-tier name collision and the final outcome after all precedence is applied.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct McpServerConflict {
pub name: String,
pub outcome: McpServerConflictAction,
pub contenders: Vec<McpServerConflictAction>,
}
#[derive(Clone, Debug)]
enum CatalogAction {
Register(Box<McpServerRegistration>),
Remove {
name: String,
source: McpServerSource,
precedence: RegistrationPrecedence,
},
}
impl CatalogAction {
fn name(&self) -> &str {
match self {
Self::Register(registration) => &registration.name,
Self::Remove { name, .. } => name,
}
}
fn precedence(&self) -> RegistrationPrecedence {
match self {
Self::Register(registration) => registration.precedence,
Self::Remove { precedence, .. } => *precedence,
}
}
fn conflict_action(&self) -> McpServerConflictAction {
match self {
Self::Register(registration) => {
McpServerConflictAction::Register(registration.source.clone())
}
Self::Remove { source, .. } => McpServerConflictAction::Remove(source.clone()),
}
}
}
/// Mutable inputs used to produce an immutable resolved catalog.
#[derive(Clone, Debug, Default)]
pub struct McpCatalogBuilder {
actions: Vec<CatalogAction>,
disabled_server_names: BTreeSet<String>,
}
impl McpCatalogBuilder {
pub fn register(&mut self, registration: McpServerRegistration) {
self.actions
.push(CatalogAction::Register(Box::new(registration)));
}
/// Applies the legacy name-scoped disabled veto after source resolution.
pub fn disable(&mut self, name: String) {
self.disabled_server_names.insert(name);
}
pub fn remove_compatibility(&mut self, name: String, id: impl Into<String>) {
self.actions.push(CatalogAction::Remove {
name,
source: McpServerSource::Compatibility { id: id.into() },
precedence: RegistrationPrecedence::Compatibility,
});
}
pub fn remove_extension(
&mut self,
name: String,
id: impl Into<String>,
contribution_order: usize,
) {
self.actions.push(CatalogAction::Remove {
name,
source: McpServerSource::Extension { id: id.into() },
precedence: RegistrationPrecedence::Extension(contribution_order),
});
}
pub fn build(mut self) -> ResolvedMcpCatalog {
// Stable sorting makes action order the tie-breaker when precedence is equal.
self.actions.sort_by_key(CatalogAction::precedence);
let mut winners = BTreeMap::<String, CatalogAction>::new();
let mut actions_by_name_and_tier = BTreeMap::<(String, u8), Vec<&CatalogAction>>::new();
for action in &self.actions {
winners.insert(action.name().to_string(), action.clone());
actions_by_name_and_tier
.entry((action.name().to_string(), action.precedence().tier()))
.or_default()
.push(action);
}
let mut conflicts = Vec::new();
for ((name, _), actions) in actions_by_name_and_tier {
if actions.len() < 2 {
continue;
}
let Some(outcome) = winners.get(&name).map(CatalogAction::conflict_action) else {
continue;
};
conflicts.push(McpServerConflict {
name,
outcome,
contenders: actions
.into_iter()
.map(CatalogAction::conflict_action)
.collect(),
});
}
let mut disabled_server_names = self.disabled_server_names;
let servers = winners
.into_iter()
.filter_map(|(name, action)| match action {
CatalogAction::Register(registration) => {
let mut registration = *registration;
// Effective disabled winners remain name-scoped vetoes for later overlays.
if !registration.config.enabled || disabled_server_names.contains(&name) {
registration.config.enabled = false;
disabled_server_names.insert(name.clone());
}
Some((
name,
ResolvedMcpServer {
source: registration.source,
config: registration.config,
},
))
}
CatalogAction::Remove { .. } => None,
})
.collect();
ResolvedMcpCatalog {
actions: self.actions,
disabled_server_names,
servers,
conflicts,
}
}
}
/// A single winning MCP registration.
#[derive(Clone, Debug, PartialEq)]
pub struct ResolvedMcpServer {
source: McpServerSource,
config: McpServerConfig,
}
impl ResolvedMcpServer {
pub fn source(&self) -> &McpServerSource {
&self.source
}
pub fn config(&self) -> &McpServerConfig {
&self.config
}
}
/// Immutable result of MCP registration resolution.
#[derive(Clone, Debug, Default)]
pub struct ResolvedMcpCatalog {
actions: Vec<CatalogAction>,
disabled_server_names: BTreeSet<String>,
servers: BTreeMap<String, ResolvedMcpServer>,
conflicts: Vec<McpServerConflict>,
}
impl ResolvedMcpCatalog {
pub fn builder() -> McpCatalogBuilder {
McpCatalogBuilder::default()
}
pub fn to_builder(&self) -> McpCatalogBuilder {
McpCatalogBuilder {
actions: self.actions.clone(),
disabled_server_names: self.disabled_server_names.clone(),
}
}
pub fn server(&self, name: &str) -> Option<&ResolvedMcpServer> {
self.servers.get(name)
}
pub fn configured_servers(&self) -> HashMap<String, McpServerConfig> {
self.servers
.iter()
.map(|(name, server)| (name.clone(), server.config.clone()))
.collect()
}
pub fn plugin_ids_by_server_name(&self) -> HashMap<String, String> {
self.servers
.iter()
.filter_map(|(name, server)| match server.source() {
McpServerSource::Plugin { plugin_id } => Some((name.clone(), plugin_id.clone())),
McpServerSource::Config
| McpServerSource::Compatibility { .. }
| McpServerSource::Extension { .. } => None,
})
.collect()
}
pub fn conflicts(&self) -> &[McpServerConflict] {
&self.conflicts
}
}
#[cfg(test)]
#[path = "catalog_tests.rs"]
mod tests;
+257
View File
@@ -0,0 +1,257 @@
use std::collections::HashMap;
use std::time::Duration;
use codex_config::AppToolApproval;
use codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID;
use codex_config::McpServerConfig;
use codex_config::McpServerToolConfig;
use codex_config::McpServerTransportConfig;
use pretty_assertions::assert_eq;
use super::McpServerConflict;
use super::McpServerConflictAction;
use super::McpServerRegistration;
use super::McpServerSource;
use super::ResolvedMcpCatalog;
fn server(url: &str) -> McpServerConfig {
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
url: url.to_string(),
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
},
environment_id: DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: true,
supports_parallel_tool_calls: true,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(7)),
tool_timeout_sec: Some(Duration::from_secs(11)),
default_tools_approval_mode: Some(AppToolApproval::Prompt),
enabled_tools: Some(vec!["read".to_string()]),
disabled_tools: Some(vec!["write".to_string()]),
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::from([(
"read".to_string(),
McpServerToolConfig {
approval_mode: Some(AppToolApproval::Approve),
},
)]),
}
}
fn plugin_source(plugin_id: &str) -> McpServerSource {
McpServerSource::Plugin {
plugin_id: plugin_id.to_string(),
}
}
fn compatibility_source(id: &str) -> McpServerSource {
McpServerSource::Compatibility { id: id.to_string() }
}
fn extension_source(id: &str) -> McpServerSource {
McpServerSource::Extension { id: id.to_string() }
}
fn register(source: McpServerSource) -> McpServerConflictAction {
McpServerConflictAction::Register(source)
}
fn remove(source: McpServerSource) -> McpServerConflictAction {
McpServerConflictAction::Remove(source)
}
#[test]
fn source_precedence_preserves_the_winning_registration() {
let extension = server("https://extension.example/mcp");
let mut plugin = server("https://plugin.example/mcp");
plugin.enabled = false;
let mut builder = ResolvedMcpCatalog::builder();
builder.register(McpServerRegistration::from_extension(
"docs".to_string(),
"hosted",
/*contribution_order*/ 0,
extension.clone(),
));
builder.register(McpServerRegistration::from_plugin(
"docs".to_string(),
"plugin@test".to_string(),
/*plugin_order*/ 0,
plugin,
));
builder.register(McpServerRegistration::from_plugin(
"docs".to_string(),
"other-plugin@test".to_string(),
/*plugin_order*/ 1,
server("https://other-plugin.example/mcp"),
));
builder.register(McpServerRegistration::from_compatibility(
"docs".to_string(),
"legacy",
server("https://compatibility.example/mcp"),
));
builder.register(McpServerRegistration::from_config(
"docs".to_string(),
server("https://config.example/mcp"),
));
let catalog = builder.build();
let resolved = catalog.server("docs").expect("resolved server");
assert_eq!(
resolved.source(),
&McpServerSource::Extension {
id: "hosted".to_string(),
}
);
assert_eq!(resolved.config(), &extension);
assert!(catalog.plugin_ids_by_server_name().is_empty());
assert_eq!(
catalog.conflicts(),
&[McpServerConflict {
name: "docs".to_string(),
outcome: register(extension_source("hosted")),
contenders: vec![
register(plugin_source("other-plugin@test")),
register(plugin_source("plugin@test")),
],
}]
);
}
#[test]
fn disabled_veto_only_disables_the_winning_registration() {
let extension = server("https://extension.example/mcp");
let mut expected = extension.clone();
expected.enabled = false;
let mut builder = ResolvedMcpCatalog::builder();
builder.register(McpServerRegistration::from_extension(
"docs".to_string(),
"hosted",
/*contribution_order*/ 0,
extension,
));
builder.disable("docs".to_string());
let actual = builder
.build()
.server("docs")
.expect("resolved server")
.config()
.clone();
assert_eq!(actual, expected);
}
#[test]
fn disabled_winner_remains_a_veto_when_the_catalog_is_extended() {
let mut disabled = server("https://config.example/mcp");
disabled.enabled = false;
let mut expected = server("https://extension.example/mcp");
expected.enabled = false;
let mut builder = ResolvedMcpCatalog::builder();
builder.register(McpServerRegistration::from_config(
"docs".to_string(),
disabled,
));
let mut builder = builder.build().to_builder();
builder.register(McpServerRegistration::from_extension(
"docs".to_string(),
"hosted",
/*contribution_order*/ 0,
server("https://extension.example/mcp"),
));
let resolved = builder.build();
assert_eq!(
resolved.server("docs"),
Some(&super::ResolvedMcpServer {
source: extension_source("hosted"),
config: expected,
})
);
}
#[test]
fn earlier_plugin_wins_with_an_explicit_conflict() {
let mut builder = ResolvedMcpCatalog::builder();
builder.register(McpServerRegistration::from_plugin(
"docs".to_string(),
"alpha@test".to_string(),
/*plugin_order*/ 0,
server("https://alpha.example/mcp"),
));
builder.register(McpServerRegistration::from_plugin(
"docs".to_string(),
"beta@test".to_string(),
/*plugin_order*/ 1,
server("https://beta.example/mcp"),
));
let catalog = builder.build();
assert_eq!(
catalog.plugin_ids_by_server_name(),
HashMap::from([("docs".to_string(), "alpha@test".to_string())])
);
assert_eq!(
catalog.conflicts(),
&[McpServerConflict {
name: "docs".to_string(),
outcome: register(plugin_source("alpha@test")),
contenders: vec![
register(plugin_source("beta@test")),
register(plugin_source("alpha@test")),
],
}]
);
}
#[test]
fn equal_precedence_uses_insertion_order_not_source_identity() {
let mut builder = ResolvedMcpCatalog::builder();
builder.register(McpServerRegistration::from_compatibility(
"docs".to_string(),
"z-first",
server("https://first.example/mcp"),
));
builder.register(McpServerRegistration::from_compatibility(
"docs".to_string(),
"a-second",
server("https://second.example/mcp"),
));
let catalog = builder.build();
assert_eq!(
catalog.server("docs"),
Some(&super::ResolvedMcpServer {
source: compatibility_source("a-second"),
config: server("https://second.example/mcp"),
})
);
let mut builder = catalog.to_builder();
builder.remove_compatibility("docs".to_string(), "remove-last");
let catalog = builder.build();
assert_eq!(catalog.server("docs"), None);
assert_eq!(
catalog.conflicts(),
&[McpServerConflict {
name: "docs".to_string(),
outcome: remove(compatibility_source("remove-last")),
contenders: vec![
register(compatibility_source("z-first")),
register(compatibility_source("a-second")),
remove(compatibility_source("remove-last")),
],
}]
);
}
@@ -348,6 +348,10 @@ impl McpConnectionManager {
!self.clients.is_empty()
}
pub(crate) fn contains_server(&self, server_name: &str) -> bool {
self.clients.contains_key(server_name)
}
/// Stop all MCP clients owned by this manager and terminate stdio server processes.
pub async fn shutdown(&self) {
self.startup_cancellation_token.cancel();
+13
View File
@@ -3,11 +3,22 @@ pub use connection_manager::tool_is_model_visible;
pub use elicitation::ElicitationReviewRequest;
pub use elicitation::ElicitationReviewer;
pub use elicitation::ElicitationReviewerHandle;
pub use resource_client::McpResourceClient;
pub use resource_client::McpResourcePage;
pub use resource_client::McpResourceReadResult;
pub use rmcp_client::MCP_SANDBOX_STATE_META_CAPABILITY;
pub use runtime::McpRuntimeContext;
pub use runtime::SandboxState;
pub use tools::ToolInfo;
pub use catalog::McpCatalogBuilder;
pub use catalog::McpServerConflict;
pub use catalog::McpServerConflictAction;
pub use catalog::McpServerRegistration;
pub use catalog::McpServerSource;
pub use catalog::ResolvedMcpCatalog;
pub use catalog::ResolvedMcpServer;
pub use mcp::CODEX_APPS_MCP_SERVER_NAME;
pub use mcp::McpConfig;
pub use mcp::ToolPluginProvenance;
@@ -54,10 +65,12 @@ pub use mcp::qualified_mcp_tool_name_prefix;
pub use tools::declared_openai_file_input_param_names;
pub(crate) mod auth_elicitation;
mod catalog;
pub(crate) mod codex_apps;
pub(crate) mod connection_manager;
pub(crate) mod elicitation;
pub(crate) mod mcp;
mod resource_client;
pub(crate) mod rmcp_client;
pub(crate) mod runtime;
pub(crate) mod server;
+9 -11
View File
@@ -37,6 +37,7 @@ use rmcp::model::ReadResourceResult;
use serde_json::Value;
use tokio_util::sync::CancellationToken;
use crate::ResolvedMcpCatalog;
use crate::codex_apps::codex_apps_tools_cache_key;
use crate::connection_manager::McpConnectionManager;
use crate::runtime::McpRuntimeContext;
@@ -135,13 +136,8 @@ pub struct McpConfig {
pub prefix_mcp_tool_names: bool,
/// Client-side elicitation capabilities advertised during MCP initialization.
pub client_elicitation_capability: ElicitationCapability,
/// Materialized MCP servers keyed by server name.
///
/// A host may add compatibility built-ins and extension overlays before
/// calling runtime entry points in this crate.
pub configured_mcp_servers: HashMap<String, McpServerConfig>,
/// Winning plugin owner for plugin-provided MCP servers, keyed by server name.
pub plugin_ids_by_mcp_server_name: HashMap<String, String>,
/// Resolved MCP registrations keyed by logical server name.
pub mcp_server_catalog: ResolvedMcpCatalog,
/// Plugin metadata used to attribute MCP tools/connectors to plugin display names.
pub plugin_capability_summaries: Vec<PluginCapabilitySummary>,
}
@@ -176,6 +172,7 @@ impl ToolPluginProvenance {
fn from_config(config: &McpConfig) -> Self {
let mut tool_plugin_provenance = Self::default();
let plugin_ids_by_mcp_server_name = config.mcp_server_catalog.plugin_ids_by_server_name();
for plugin in &config.plugin_capability_summaries {
for connector_id in &plugin.app_connector_ids {
tool_plugin_provenance
@@ -185,7 +182,9 @@ impl ToolPluginProvenance {
.push(plugin.display_name.clone());
}
for server_name in &plugin.mcp_server_names {
for server_name in plugin.mcp_server_names.iter().filter(|server_name| {
plugin_ids_by_mcp_server_name.get(*server_name) == Some(&plugin.config_name)
}) {
tool_plugin_provenance
.plugin_display_names_by_mcp_server_name
.entry(server_name.clone())
@@ -206,8 +205,7 @@ impl ToolPluginProvenance {
plugin_names.sort_unstable();
plugin_names.dedup();
}
tool_plugin_provenance.plugin_ids_by_mcp_server_name =
config.plugin_ids_by_mcp_server_name.clone();
tool_plugin_provenance.plugin_ids_by_mcp_server_name = plugin_ids_by_mcp_server_name;
tool_plugin_provenance
}
@@ -218,7 +216,7 @@ pub fn host_owned_codex_apps_enabled(config: &McpConfig, auth: Option<&CodexAuth
}
pub fn configured_mcp_servers(config: &McpConfig) -> HashMap<String, McpServerConfig> {
config.configured_mcp_servers.clone()
config.mcp_server_catalog.configured_servers()
}
pub fn effective_mcp_servers(
+24 -14
View File
@@ -1,4 +1,5 @@
use super::*;
use crate::McpServerRegistration;
use codex_config::Constrained;
use codex_config::types::AppToolApproval;
use codex_login::CodexAuth;
@@ -28,8 +29,7 @@ fn test_mcp_config(codex_home: PathBuf) -> McpConfig {
apps_enabled: false,
prefix_mcp_tool_names: true,
client_elicitation_capability: ElicitationCapability::default(),
configured_mcp_servers: HashMap::new(),
plugin_ids_by_mcp_server_name: HashMap::new(),
mcp_server_catalog: ResolvedMcpCatalog::default(),
plugin_capability_summaries: Vec::new(),
}
}
@@ -122,16 +122,24 @@ fn mcp_prompt_auto_approval_rejects_auto_mode_in_default_permission_mode() {
#[test]
fn tool_plugin_provenance_collects_app_and_mcp_sources() {
let mut config = test_mcp_config(PathBuf::new());
config.plugin_ids_by_mcp_server_name =
HashMap::from([("alpha".to_string(), "alpha@test".to_string())]);
let mut catalog = ResolvedMcpCatalog::builder();
catalog.register(McpServerRegistration::from_plugin(
"alpha".to_string(),
"alpha@test".to_string(),
/*plugin_order*/ 0,
codex_apps_mcp_server_config("https://alpha.example", /*apps_mcp_product_sku*/ None),
));
config.mcp_server_catalog = catalog.build();
config.plugin_capability_summaries = vec![
PluginCapabilitySummary {
config_name: "alpha@test".to_string(),
display_name: "alpha-plugin".to_string(),
app_connector_ids: vec![AppConnectorId("connector_example".to_string())],
mcp_server_names: vec!["alpha".to_string()],
..PluginCapabilitySummary::default()
},
PluginCapabilitySummary {
config_name: "beta@test".to_string(),
display_name: "beta-plugin".to_string(),
app_connector_ids: vec![
AppConnectorId("connector_example".to_string()),
@@ -156,10 +164,10 @@ fn tool_plugin_provenance_collects_app_and_mcp_sources() {
vec!["beta-plugin".to_string()],
),
]),
plugin_display_names_by_mcp_server_name: HashMap::from([
("alpha".to_string(), vec!["alpha-plugin".to_string()]),
("beta".to_string(), vec!["beta-plugin".to_string()]),
]),
plugin_display_names_by_mcp_server_name: HashMap::from([(
"alpha".to_string(),
vec!["alpha-plugin".to_string()],
)]),
plugin_ids_by_mcp_server_name: HashMap::from([(
"alpha".to_string(),
"alpha@test".to_string(),
@@ -235,7 +243,8 @@ async fn effective_mcp_servers_preserve_runtime_servers() {
config.apps_enabled = true;
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
config.configured_mcp_servers.insert(
let mut catalog = ResolvedMcpCatalog::builder();
catalog.register(McpServerRegistration::from_config(
"sample".to_string(),
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
@@ -259,8 +268,8 @@ async fn effective_mcp_servers_preserve_runtime_servers() {
oauth_resource: None,
tools: HashMap::new(),
},
);
config.configured_mcp_servers.insert(
));
catalog.register(McpServerRegistration::from_config(
"docs".to_string(),
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
@@ -284,14 +293,15 @@ async fn effective_mcp_servers_preserve_runtime_servers() {
oauth_resource: None,
tools: HashMap::new(),
},
);
config.configured_mcp_servers.insert(
));
catalog.register(McpServerRegistration::from_config(
CODEX_APPS_MCP_SERVER_NAME.to_string(),
codex_apps_mcp_server_config(
&config.chatgpt_base_url,
config.apps_mcp_product_sku.as_deref(),
),
);
));
config.mcp_server_catalog = catalog.build();
let effective = effective_mcp_servers(&config, Some(&auth));
+108
View File
@@ -0,0 +1,108 @@
use std::sync::Arc;
use anyhow::Context;
use anyhow::Result;
use arc_swap::ArcSwap;
use codex_protocol::mcp::Resource;
use codex_protocol::mcp::ResourceContent;
use rmcp::model::PaginatedRequestParams;
use rmcp::model::ReadResourceRequestParams;
use crate::McpConnectionManager;
/// One page of resources returned by an MCP server.
#[derive(Clone, Debug, PartialEq)]
pub struct McpResourcePage {
/// Resources advertised on this page.
pub resources: Vec<Resource>,
/// Opaque cursor to supply when requesting the next page.
pub next_cursor: Option<String>,
}
/// Contents returned after reading one MCP resource.
#[derive(Clone, Debug, PartialEq)]
pub struct McpResourceReadResult {
/// Text or blob content returned for the requested resource.
pub contents: Vec<ResourceContent>,
}
/// Session-scoped access to MCP resources through the currently installed manager.
///
/// The client retains the manager's shared publication handle rather than a manager
/// snapshot, so calls automatically use replacements installed during startup and refresh.
#[derive(Clone)]
pub struct McpResourceClient {
manager: Arc<ArcSwap<McpConnectionManager>>,
}
impl std::fmt::Debug for McpResourceClient {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("McpResourceClient")
.finish_non_exhaustive()
}
}
impl McpResourceClient {
/// Creates a resource client backed by the session's replaceable MCP manager.
pub fn new(manager: Arc<ArcSwap<McpConnectionManager>>) -> Self {
Self { manager }
}
/// Returns whether the current manager contains the named server.
///
/// This does not wait for server startup or imply that startup succeeded.
pub async fn has_server(&self, server: &str) -> bool {
self.manager.load_full().contains_server(server)
}
/// Lists one resource page from the named server.
pub async fn list_resources(
&self,
server: &str,
cursor: Option<String>,
) -> Result<McpResourcePage> {
let params =
cursor.map(|cursor| PaginatedRequestParams::default().with_cursor(Some(cursor)));
let result = self
.manager
.load_full()
.list_resources(server, params)
.await?;
let resources = result
.resources
.into_iter()
.map(resource_from_rmcp)
.collect::<Result<Vec<_>>>()?;
Ok(McpResourcePage {
resources,
next_cursor: result.next_cursor,
})
}
/// Reads one resource from the named server.
pub async fn read_resource(&self, server: &str, uri: &str) -> Result<McpResourceReadResult> {
let result = self
.manager
.load_full()
.read_resource(server, ReadResourceRequestParams::new(uri.to_string()))
.await?;
let contents = result
.contents
.into_iter()
.map(resource_content_from_rmcp)
.collect::<Result<Vec<_>>>()?;
Ok(McpResourceReadResult { contents })
}
}
fn resource_from_rmcp(resource: rmcp::model::Resource) -> Result<Resource> {
let value = serde_json::to_value(resource).context("failed to serialize MCP resource")?;
Resource::from_mcp_value(value).context("failed to convert MCP resource")
}
fn resource_content_from_rmcp(content: rmcp::model::ResourceContents) -> Result<ResourceContent> {
let value =
serde_json::to_value(content).context("failed to serialize MCP resource content")?;
serde_json::from_value(value).context("failed to convert MCP resource content")
}
+1
View File
@@ -25,6 +25,7 @@ codex-network-proxy = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-path = { workspace = true }
codex-utils-path-uri = { workspace = true }
dunce = { workspace = true }
futures = { workspace = true, features = ["alloc", "std"] }
gethostname = { workspace = true }

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