mirror of
https://github.com/openai/codex.git
synced 2026-11-02 19:43:24 +00:00
Merge remote-tracking branch 'origin/main' into cconger/code-mode-host-skeleton
# Conflicts: # codex-rs/Cargo.toml
This commit is contained in:
@@ -1111,31 +1111,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
|
||||
@@ -1235,6 +1225,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 }}
|
||||
@@ -1363,14 +1365,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
|
||||
Generated
+37
-1
@@ -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",
|
||||
@@ -2550,6 +2554,7 @@ dependencies = [
|
||||
"codex-protocol",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-path",
|
||||
"codex-utils-path-uri",
|
||||
"core-foundation 0.9.4",
|
||||
"dns-lookup",
|
||||
"dunce",
|
||||
@@ -2637,6 +2642,7 @@ dependencies = [
|
||||
"codex-features",
|
||||
"codex-feedback",
|
||||
"codex-git-utils",
|
||||
"codex-home",
|
||||
"codex-hooks",
|
||||
"codex-image-generation-extension",
|
||||
"codex-install-context",
|
||||
@@ -2670,6 +2676,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",
|
||||
@@ -2740,6 +2747,7 @@ dependencies = [
|
||||
"codex-exec-server",
|
||||
"codex-extension-api",
|
||||
"codex-features",
|
||||
"codex-home",
|
||||
"codex-login",
|
||||
"codex-model-provider-info",
|
||||
"codex-models-manager",
|
||||
@@ -2804,6 +2812,7 @@ dependencies = [
|
||||
"codex-skills",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-output-truncation",
|
||||
"codex-utils-path-uri",
|
||||
"codex-utils-plugins",
|
||||
"dirs",
|
||||
"dunce",
|
||||
@@ -2884,6 +2893,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",
|
||||
@@ -2961,6 +2971,7 @@ dependencies = [
|
||||
"codex-context-fragments",
|
||||
"codex-protocol",
|
||||
"codex-tools",
|
||||
"codex-utils-absolute-path",
|
||||
"pretty_assertions",
|
||||
"tokio",
|
||||
]
|
||||
@@ -3040,6 +3051,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"codex-protocol",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-path-uri",
|
||||
"serde",
|
||||
]
|
||||
|
||||
@@ -3063,6 +3075,7 @@ dependencies = [
|
||||
"codex-file-system",
|
||||
"codex-protocol",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-path-uri",
|
||||
"futures",
|
||||
"gix",
|
||||
"once_cell",
|
||||
@@ -3109,6 +3122,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"
|
||||
@@ -3256,6 +3280,7 @@ name = "codex-mcp"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
"async-channel",
|
||||
"codex-api",
|
||||
"codex-async-utils",
|
||||
@@ -3309,6 +3334,7 @@ dependencies = [
|
||||
"codex-core",
|
||||
"codex-exec-server",
|
||||
"codex-extension-api",
|
||||
"codex-home",
|
||||
"codex-login",
|
||||
"codex-protocol",
|
||||
"codex-shell-command",
|
||||
@@ -3850,15 +3876,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]]
|
||||
@@ -4232,6 +4265,7 @@ dependencies = [
|
||||
"codex-exec-server",
|
||||
"codex-login",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-path-uri",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
@@ -4615,6 +4649,7 @@ dependencies = [
|
||||
"codex-exec-server",
|
||||
"codex-extension-api",
|
||||
"codex-features",
|
||||
"codex-home",
|
||||
"codex-hooks",
|
||||
"codex-login",
|
||||
"codex-model-provider-info",
|
||||
@@ -4622,6 +4657,7 @@ dependencies = [
|
||||
"codex-protocol",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-cargo-bin",
|
||||
"codex-utils-path-uri",
|
||||
"ctor 0.6.3",
|
||||
"futures",
|
||||
"notify",
|
||||
|
||||
@@ -24,6 +24,7 @@ members = [
|
||||
"code-mode-client",
|
||||
"code-mode-host",
|
||||
"code-mode-protocol",
|
||||
"codex-home",
|
||||
"cloud-config",
|
||||
"cloud-tasks",
|
||||
"cloud-tasks-client",
|
||||
@@ -162,6 +163,7 @@ codex-cloud-tasks-mock-client = { path = "cloud-tasks-mock-client" }
|
||||
codex-code-mode = { path = "code-mode" }
|
||||
codex-code-mode-client = { path = "code-mode-client" }
|
||||
codex-code-mode-protocol = { path = "code-mode-protocol" }
|
||||
codex-home = { path = "codex-home" }
|
||||
codex-config = { path = "config" }
|
||||
codex-connectors = { path = "connectors" }
|
||||
codex-context-fragments = { path = "context-fragments" }
|
||||
@@ -242,6 +244,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" }
|
||||
|
||||
@@ -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
|
||||
@@ -2667,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 =
|
||||
@@ -2749,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),
|
||||
@@ -2774,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]
|
||||
@@ -3757,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 {
|
||||
@@ -3764,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,
|
||||
@@ -3833,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(
|
||||
|
||||
@@ -633,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)]
|
||||
@@ -762,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>,
|
||||
@@ -999,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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
},
|
||||
))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -532,6 +532,13 @@
|
||||
"personalAccessToken"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Amazon Bedrock bearer token managed by Codex.",
|
||||
"enum": [
|
||||
"bedrockApiKey"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+20
-5
@@ -6416,6 +6416,12 @@
|
||||
"AppSummary": {
|
||||
"description": "EXPERIMENTAL - app metadata summary for plugin responses.",
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -6433,15 +6439,11 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"needsAuth": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"needsAuth"
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@@ -6453,6 +6455,12 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"category": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -6728,6 +6736,13 @@
|
||||
"personalAccessToken"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Amazon Bedrock bearer token managed by Codex.",
|
||||
"enum": [
|
||||
"bedrockApiKey"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+20
-5
@@ -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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -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";
|
||||
|
||||
@@ -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, };
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -206,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.
|
||||
|
||||
@@ -27,6 +27,9 @@ 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>,
|
||||
@@ -73,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())
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -205,6 +206,9 @@ mod tests {
|
||||
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()),
|
||||
|
||||
@@ -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;
|
||||
@@ -339,6 +340,9 @@ impl MessageProcessor {
|
||||
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(),
|
||||
|
||||
@@ -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(¶ms.path).map_err(map_fs_error)?;
|
||||
let bytes = self
|
||||
.file_system()?
|
||||
.read_file(¶ms.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(¶ms.path).map_err(map_fs_error)?;
|
||||
self.file_system()?
|
||||
.write_file(¶ms.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(¶ms.path).map_err(map_fs_error)?;
|
||||
self.file_system()?
|
||||
.create_directory(
|
||||
¶ms.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(¶ms.path).map_err(map_fs_error)?;
|
||||
let metadata = self
|
||||
.file_system()?
|
||||
.get_metadata(¶ms.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(¶ms.path).map_err(map_fs_error)?;
|
||||
let entries = self
|
||||
.file_system()?
|
||||
.read_directory(¶ms.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(¶ms.path).map_err(map_fs_error)?;
|
||||
self.file_system()?
|
||||
.remove(
|
||||
¶ms.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(¶ms.source_path).map_err(map_fs_error)?;
|
||||
let destination_path =
|
||||
PathUri::from_abs_path(¶ms.destination_path).map_err(map_fs_error)?;
|
||||
self.file_system()?
|
||||
.copy(
|
||||
¶ms.source_path,
|
||||
¶ms.destination_path,
|
||||
&source_path,
|
||||
&destination_path,
|
||||
CopyOptions {
|
||||
recursive: params.recursive,
|
||||
},
|
||||
|
||||
@@ -1035,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
|
||||
@@ -1118,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)
|
||||
}
|
||||
};
|
||||
@@ -1572,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()
|
||||
}
|
||||
@@ -1887,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();
|
||||
@@ -1907,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],
|
||||
@@ -1978,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()
|
||||
}
|
||||
@@ -2071,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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
)?;
|
||||
|
||||
@@ -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 <hosted> 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)),
|
||||
]))
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -2437,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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2566,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
|
||||
@@ -3471,6 +3483,7 @@ mod tests {
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
bedrock_api_key: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
@@ -3489,6 +3502,7 @@ mod tests {
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
bedrock_api_key: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
@@ -3509,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");
|
||||
@@ -3531,6 +3546,7 @@ mod tests {
|
||||
last_refresh: None,
|
||||
agent_identity: None,
|
||||
personal_access_token: None,
|
||||
bedrock_api_key: None,
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
|
||||
@@ -395,6 +395,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");
|
||||
|
||||
+118
-14
@@ -36,11 +36,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"))]
|
||||
@@ -75,6 +76,7 @@ use codex_core::config::resolve_profile_v2_config_path;
|
||||
use codex_features::FEATURES;
|
||||
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;
|
||||
@@ -180,6 +182,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),
|
||||
|
||||
@@ -354,6 +359,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.
|
||||
@@ -685,10 +700,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;
|
||||
|
||||
@@ -697,13 +713,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
|
||||
@@ -711,19 +729,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)?;
|
||||
}
|
||||
@@ -829,6 +851,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) => {
|
||||
@@ -1233,6 +1266,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,
|
||||
@@ -1597,6 +1644,7 @@ fn profile_v2_for_subcommand<'a>(
|
||||
| Subcommand::Review(_)
|
||||
| Subcommand::Resume(_)
|
||||
| Subcommand::Archive(_)
|
||||
| Subcommand::Delete(_)
|
||||
| Subcommand::Unarchive(_)
|
||||
| Subcommand::Fork(_)
|
||||
| Subcommand::Mcp(_)
|
||||
@@ -1605,7 +1653,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`."
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -1895,7 +1943,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(())
|
||||
@@ -2026,6 +2083,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,
|
||||
@@ -2868,6 +2926,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() {
|
||||
@@ -2986,12 +3055,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,
|
||||
}
|
||||
@@ -3002,7 +3072,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,
|
||||
};
|
||||
@@ -3010,6 +3080,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(
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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]] =
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "codex-home",
|
||||
crate_name = "codex_home",
|
||||
)
|
||||
@@ -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]
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mod instructions;
|
||||
|
||||
pub use instructions::CodexHomeUserInstructionsProvider;
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) => ®istration.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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::strict_config::config_error_from_ignored_toml_value_fields;
|
||||
use codex_file_system::ExecutorFileSystem;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -106,7 +107,8 @@ pub(super) async fn read_config_from_path(
|
||||
log_missing_as_info: bool,
|
||||
strict_config: bool,
|
||||
) -> io::Result<Option<TomlValue>> {
|
||||
match fs.read_file_text(path, /*sandbox*/ None).await {
|
||||
let path_uri = PathUri::from_abs_path(path)?;
|
||||
match fs.read_file_text(&path_uri, /*sandbox*/ None).await {
|
||||
Ok(contents) => match toml::from_str::<TomlValue>(&contents) {
|
||||
Ok(value) => {
|
||||
if strict_config {
|
||||
|
||||
@@ -40,6 +40,7 @@ use codex_protocol::config_types::TrustLevel;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use dunce::canonicalize as normalize_path;
|
||||
use serde::Deserialize;
|
||||
use std::io;
|
||||
@@ -471,7 +472,8 @@ async fn load_config_toml_for_required_layer(
|
||||
strict_config: bool,
|
||||
create_entry: impl FnOnce(TomlValue) -> ConfigLayerEntry,
|
||||
) -> io::Result<ConfigLayerEntry> {
|
||||
let toml_value = match fs.read_file_text(toml_file, /*sandbox*/ None).await {
|
||||
let toml_file_uri = PathUri::from_abs_path(toml_file)?;
|
||||
let toml_value = match fs.read_file_text(&toml_file_uri, /*sandbox*/ None).await {
|
||||
Ok(contents) => {
|
||||
let config_parent = toml_file.as_path().parent().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
@@ -566,8 +568,9 @@ pub async fn load_requirements_toml(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
requirements_toml_file: &AbsolutePathBuf,
|
||||
) -> io::Result<Option<RequirementsLayerEntry>> {
|
||||
let requirements_toml_file_uri = PathUri::from_abs_path(requirements_toml_file)?;
|
||||
match fs
|
||||
.read_file_text(requirements_toml_file, /*sandbox*/ None)
|
||||
.read_file_text(&requirements_toml_file_uri, /*sandbox*/ None)
|
||||
.await
|
||||
{
|
||||
Ok(contents) => {
|
||||
@@ -1135,8 +1138,9 @@ async fn find_project_root(
|
||||
for ancestor in cwd.ancestors() {
|
||||
for marker in project_root_markers {
|
||||
let marker_path = ancestor.join(marker);
|
||||
let marker_path_uri = PathUri::from_abs_path(&marker_path)?;
|
||||
if fs
|
||||
.get_metadata(&marker_path, /*sandbox*/ None)
|
||||
.get_metadata(&marker_path_uri, /*sandbox*/ None)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
@@ -1151,14 +1155,20 @@ async fn find_git_checkout_root(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
cwd: &AbsolutePathBuf,
|
||||
) -> Option<AbsolutePathBuf> {
|
||||
let base = match fs.get_metadata(cwd, /*sandbox*/ None).await {
|
||||
let cwd_uri = PathUri::from_abs_path(cwd).ok()?;
|
||||
let base = match fs.get_metadata(&cwd_uri, /*sandbox*/ None).await {
|
||||
Ok(metadata) if metadata.is_directory => cwd.clone(),
|
||||
_ => cwd.parent()?,
|
||||
};
|
||||
|
||||
for dir in base.ancestors() {
|
||||
let dot_git = dir.join(".git");
|
||||
if fs.get_metadata(&dot_git, /*sandbox*/ None).await.is_ok() {
|
||||
let dot_git_uri = PathUri::from_abs_path(&dot_git).ok()?;
|
||||
if fs
|
||||
.get_metadata(&dot_git_uri, /*sandbox*/ None)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Some(dir);
|
||||
}
|
||||
}
|
||||
@@ -1206,8 +1216,9 @@ async fn load_project_layers(
|
||||
let mut startup_warnings = Vec::new();
|
||||
for dir in dirs {
|
||||
let dot_codex_abs = dir.join(".codex");
|
||||
let dot_codex_uri = PathUri::from_abs_path(&dot_codex_abs)?;
|
||||
if !fs
|
||||
.get_metadata(&dot_codex_abs, /*sandbox*/ None)
|
||||
.get_metadata(&dot_codex_uri, /*sandbox*/ None)
|
||||
.await
|
||||
.map(|metadata| metadata.is_directory)
|
||||
.unwrap_or(false)
|
||||
@@ -1224,7 +1235,8 @@ async fn load_project_layers(
|
||||
continue;
|
||||
}
|
||||
let config_file = dot_codex_abs.join(CONFIG_TOML_FILE);
|
||||
match fs.read_file_text(&config_file, /*sandbox*/ None).await {
|
||||
let config_file_uri = PathUri::from_abs_path(&config_file)?;
|
||||
match fs.read_file_text(&config_file_uri, /*sandbox*/ None).await {
|
||||
Ok(contents) => {
|
||||
let config: TomlValue = match toml::from_str(&contents) {
|
||||
Ok(config) => config,
|
||||
@@ -1327,8 +1339,9 @@ async fn merge_root_checkout_project_hooks(
|
||||
return Ok(config);
|
||||
};
|
||||
let hooks_config_file = hooks_config_folder.join(CONFIG_TOML_FILE);
|
||||
let hooks_config_file_uri = PathUri::from_abs_path(&hooks_config_file)?;
|
||||
let root_config = match fs
|
||||
.read_file_text(&hooks_config_file, /*sandbox*/ None)
|
||||
.read_file_text(&hooks_config_file_uri, /*sandbox*/ None)
|
||||
.await
|
||||
{
|
||||
Ok(contents) => {
|
||||
|
||||
@@ -7,8 +7,8 @@ use codex_file_system::FileSystemResult;
|
||||
use codex_file_system::FileSystemSandboxContext;
|
||||
use codex_file_system::ReadDirectoryEntry;
|
||||
use codex_file_system::RemoveOptions;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
use tempfile::tempdir;
|
||||
|
||||
struct TestFileSystem;
|
||||
@@ -17,35 +17,26 @@ struct TestFileSystem;
|
||||
impl ExecutorFileSystem for TestFileSystem {
|
||||
async fn canonicalize(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
path: &PathUri,
|
||||
_sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<AbsolutePathBuf> {
|
||||
path.canonicalize()
|
||||
}
|
||||
|
||||
async fn join(
|
||||
&self,
|
||||
base_path: &AbsolutePathBuf,
|
||||
path: &Path,
|
||||
) -> FileSystemResult<AbsolutePathBuf> {
|
||||
Ok(base_path.join(path))
|
||||
}
|
||||
|
||||
async fn parent(&self, path: &AbsolutePathBuf) -> FileSystemResult<Option<AbsolutePathBuf>> {
|
||||
Ok(path.parent())
|
||||
) -> FileSystemResult<PathUri> {
|
||||
let path = path.to_abs_path()?;
|
||||
let canonicalized = path.canonicalize()?;
|
||||
PathUri::from_abs_path(&canonicalized)
|
||||
}
|
||||
|
||||
async fn read_file(
|
||||
&self,
|
||||
path: &AbsolutePathBuf,
|
||||
path: &PathUri,
|
||||
_sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<Vec<u8>> {
|
||||
let path = path.to_abs_path()?;
|
||||
tokio::fs::read(path.as_path()).await
|
||||
}
|
||||
|
||||
async fn write_file(
|
||||
&self,
|
||||
_path: &AbsolutePathBuf,
|
||||
_path: &PathUri,
|
||||
_contents: Vec<u8>,
|
||||
_sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
@@ -54,7 +45,7 @@ impl ExecutorFileSystem for TestFileSystem {
|
||||
|
||||
async fn create_directory(
|
||||
&self,
|
||||
_path: &AbsolutePathBuf,
|
||||
_path: &PathUri,
|
||||
_create_directory_options: CreateDirectoryOptions,
|
||||
_sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
@@ -63,7 +54,7 @@ impl ExecutorFileSystem for TestFileSystem {
|
||||
|
||||
async fn get_metadata(
|
||||
&self,
|
||||
_path: &AbsolutePathBuf,
|
||||
_path: &PathUri,
|
||||
_sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<FileMetadata> {
|
||||
unimplemented!("test filesystem only supports reads")
|
||||
@@ -71,7 +62,7 @@ impl ExecutorFileSystem for TestFileSystem {
|
||||
|
||||
async fn read_directory(
|
||||
&self,
|
||||
_path: &AbsolutePathBuf,
|
||||
_path: &PathUri,
|
||||
_sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<Vec<ReadDirectoryEntry>> {
|
||||
unimplemented!("test filesystem only supports reads")
|
||||
@@ -79,7 +70,7 @@ impl ExecutorFileSystem for TestFileSystem {
|
||||
|
||||
async fn remove(
|
||||
&self,
|
||||
_path: &AbsolutePathBuf,
|
||||
_path: &PathUri,
|
||||
_remove_options: RemoveOptions,
|
||||
_sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
@@ -88,8 +79,8 @@ impl ExecutorFileSystem for TestFileSystem {
|
||||
|
||||
async fn copy(
|
||||
&self,
|
||||
_source_path: &AbsolutePathBuf,
|
||||
_destination_path: &AbsolutePathBuf,
|
||||
_source_path: &PathUri,
|
||||
_destination_path: &PathUri,
|
||||
_copy_options: CopyOptions,
|
||||
_sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<()> {
|
||||
|
||||
@@ -20,6 +20,7 @@ codex-analytics = { workspace = true }
|
||||
codex-config = { workspace = true }
|
||||
codex-core = { workspace = true }
|
||||
codex-extension-api = { workspace = true }
|
||||
codex-home = { workspace = true }
|
||||
codex-exec-server = { workspace = true }
|
||||
codex-features = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
|
||||
@@ -48,9 +48,14 @@ pub use codex_core::skills::SkillsManager;
|
||||
pub use codex_core::thread_store_from_config;
|
||||
pub use codex_exec_server::EnvironmentManager;
|
||||
pub use codex_exec_server::ExecServerRuntimePaths;
|
||||
pub use codex_extension_api::LoadUserInstructionsFuture;
|
||||
pub use codex_extension_api::LoadedUserInstructions;
|
||||
pub use codex_extension_api::UserInstructions;
|
||||
pub use codex_extension_api::UserInstructionsProvider;
|
||||
pub use codex_extension_api::empty_extension_registry;
|
||||
pub use codex_features::Feature;
|
||||
pub use codex_features::Features;
|
||||
pub use codex_home::CodexHomeUserInstructionsProvider;
|
||||
pub use codex_login::AuthManager;
|
||||
pub use codex_login::default_client::set_default_originator;
|
||||
pub use codex_model_provider_info::OPENAI_PROVIDER_ID;
|
||||
|
||||
@@ -61,6 +61,12 @@ pub struct PluginHookLoadOutcome {
|
||||
pub hook_load_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginAppMetadata {
|
||||
pub id: AppConnectorId,
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
enum PluginLoadScope<'a> {
|
||||
AllCapabilities {
|
||||
restriction_product: Option<Product>,
|
||||
@@ -123,6 +129,7 @@ struct PluginAppFile {
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct PluginAppConfig {
|
||||
id: String,
|
||||
category: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn load_plugins_from_layer_stack(
|
||||
@@ -848,6 +855,14 @@ fn default_mcp_config_paths(plugin_root: &Path) -> Vec<AbsolutePathBuf> {
|
||||
}
|
||||
|
||||
pub async fn load_plugin_apps(plugin_root: &Path) -> Vec<AppConnectorId> {
|
||||
load_plugin_app_metadata(plugin_root)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|app| app.id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn load_plugin_app_metadata(plugin_root: &Path) -> Vec<PluginAppMetadata> {
|
||||
if let Some(manifest) = load_plugin_manifest(plugin_root) {
|
||||
return load_apps_from_paths(
|
||||
plugin_root,
|
||||
@@ -858,6 +873,16 @@ pub async fn load_plugin_apps(plugin_root: &Path) -> Vec<AppConnectorId> {
|
||||
load_apps_from_paths(plugin_root, default_app_config_paths(plugin_root)).await
|
||||
}
|
||||
|
||||
pub fn plugin_app_metadata_from_value(value: &JsonValue) -> Vec<PluginAppMetadata> {
|
||||
let Ok(parsed) = serde_json::from_value::<PluginAppFile>(value.clone()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut apps = plugin_app_metadata_from_file(parsed, /*plugin_root*/ None);
|
||||
let mut seen_connector_ids = HashSet::new();
|
||||
apps.retain(|app| seen_connector_ids.insert(app.id.0.clone()));
|
||||
apps
|
||||
}
|
||||
|
||||
fn plugin_app_config_paths(
|
||||
plugin_root: &Path,
|
||||
manifest_paths: &PluginManifestPaths,
|
||||
@@ -994,8 +1019,8 @@ fn append_plugin_hook_file(
|
||||
async fn load_apps_from_paths(
|
||||
plugin_root: &Path,
|
||||
app_config_paths: Vec<AbsolutePathBuf>,
|
||||
) -> Vec<AppConnectorId> {
|
||||
let mut connector_ids = Vec::new();
|
||||
) -> Vec<PluginAppMetadata> {
|
||||
let mut apps = Vec::new();
|
||||
for app_config_path in app_config_paths {
|
||||
let Ok(contents) = tokio::fs::read_to_string(app_config_path.as_path()).await else {
|
||||
continue;
|
||||
@@ -1011,21 +1036,40 @@ async fn load_apps_from_paths(
|
||||
}
|
||||
};
|
||||
|
||||
connector_ids.extend(parsed.apps.into_values().filter_map(|app| {
|
||||
if app.id.trim().is_empty() {
|
||||
warn!(
|
||||
plugin = %plugin_root.display(),
|
||||
"plugin app config is missing an app id"
|
||||
);
|
||||
None
|
||||
} else {
|
||||
Some(AppConnectorId(app.id))
|
||||
}
|
||||
}));
|
||||
apps.extend(plugin_app_metadata_from_file(parsed, Some(plugin_root)));
|
||||
}
|
||||
let mut seen_connector_ids = HashSet::new();
|
||||
connector_ids.retain(|connector_id| seen_connector_ids.insert(connector_id.0.clone()));
|
||||
connector_ids
|
||||
apps.retain(|app| seen_connector_ids.insert(app.id.0.clone()));
|
||||
apps
|
||||
}
|
||||
|
||||
fn plugin_app_metadata_from_file(
|
||||
parsed: PluginAppFile,
|
||||
plugin_root: Option<&Path>,
|
||||
) -> Vec<PluginAppMetadata> {
|
||||
parsed
|
||||
.apps
|
||||
.into_values()
|
||||
.filter_map(|app| {
|
||||
if app.id.trim().is_empty() {
|
||||
if let Some(plugin_root) = plugin_root {
|
||||
warn!(
|
||||
plugin = %plugin_root.display(),
|
||||
"plugin app config is missing an app id"
|
||||
);
|
||||
}
|
||||
None
|
||||
} else {
|
||||
Some(PluginAppMetadata {
|
||||
id: AppConnectorId(app.id),
|
||||
category: app
|
||||
.category
|
||||
.map(|category| category.trim().to_string())
|
||||
.filter(|category| !category.is_empty()),
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn plugin_telemetry_metadata_from_root(
|
||||
@@ -1063,7 +1107,10 @@ pub async fn plugin_telemetry_metadata_from_root(
|
||||
plugin_root.as_path(),
|
||||
plugin_app_config_paths(plugin_root.as_path(), manifest_paths),
|
||||
)
|
||||
.await,
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|app| app.id)
|
||||
.collect(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::loader::PluginHookLoadOutcome;
|
||||
use crate::loader::configured_curated_plugin_ids_from_codex_home;
|
||||
use crate::loader::curated_plugin_cache_version;
|
||||
use crate::loader::installed_plugin_telemetry_metadata;
|
||||
use crate::loader::load_plugin_apps;
|
||||
use crate::loader::load_plugin_app_metadata;
|
||||
use crate::loader::load_plugin_hooks;
|
||||
use crate::loader::load_plugin_hooks_from_layer_stack;
|
||||
use crate::loader::load_plugin_mcp_servers;
|
||||
@@ -48,6 +48,7 @@ use crate::store::PluginInstallResult as StorePluginInstallResult;
|
||||
use crate::store::PluginStore;
|
||||
use crate::store::PluginStoreError;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::clear_user_plugin;
|
||||
use codex_config::set_user_plugin_enabled;
|
||||
@@ -203,6 +204,13 @@ fn featured_plugin_ids_cache_key(
|
||||
}
|
||||
}
|
||||
|
||||
fn project_plugin_load_outcome_for_auth(
|
||||
outcome: PluginLoadOutcome,
|
||||
_auth_mode: Option<AuthMode>,
|
||||
) -> PluginLoadOutcome {
|
||||
outcome
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginInstallRequest {
|
||||
pub plugin_name: String,
|
||||
@@ -246,6 +254,7 @@ pub struct PluginDetail {
|
||||
pub disabled_skill_paths: HashSet<AbsolutePathBuf>,
|
||||
pub hooks: Vec<PluginHookSummary>,
|
||||
pub apps: Vec<AppConnectorId>,
|
||||
pub app_category_by_id: HashMap<String, String>,
|
||||
pub mcp_server_names: Vec<String>,
|
||||
pub details_unavailable_reason: Option<PluginDetailsUnavailableReason>,
|
||||
}
|
||||
@@ -319,6 +328,7 @@ pub struct PluginsManager {
|
||||
remote_installed_plugins_cache_refresh_state: RwLock<RemoteInstalledPluginsCacheRefreshState>,
|
||||
global_remote_catalog_cache_refresh_state: RwLock<GlobalRemoteCatalogCacheRefreshState>,
|
||||
restriction_product: Option<Product>,
|
||||
auth_mode: RwLock<Option<AuthMode>>,
|
||||
analytics_events_client: RwLock<Option<AnalyticsEventsClient>>,
|
||||
}
|
||||
|
||||
@@ -375,10 +385,30 @@ impl PluginsManager {
|
||||
GlobalRemoteCatalogCacheRefreshState::default(),
|
||||
),
|
||||
restriction_product,
|
||||
auth_mode: RwLock::new(None),
|
||||
analytics_events_client: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_auth_mode(&self, auth_mode: Option<AuthMode>) -> bool {
|
||||
let mut stored_auth_mode = match self.auth_mode.write() {
|
||||
Ok(auth_mode_guard) => auth_mode_guard,
|
||||
Err(err) => err.into_inner(),
|
||||
};
|
||||
if *stored_auth_mode == auth_mode {
|
||||
return false;
|
||||
}
|
||||
*stored_auth_mode = auth_mode;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn auth_mode(&self) -> Option<AuthMode> {
|
||||
match self.auth_mode.read() {
|
||||
Ok(auth_mode_guard) => *auth_mode_guard,
|
||||
Err(err) => *err.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_analytics_events_client(&self, analytics_events_client: AnalyticsEventsClient) {
|
||||
let mut stored_client = match self.analytics_events_client.write() {
|
||||
Ok(client_guard) => client_guard,
|
||||
@@ -417,7 +447,7 @@ impl PluginsManager {
|
||||
remote_plugin_enabled: config.remote_plugin_enabled,
|
||||
};
|
||||
if !force_reload && let Some(outcome) = self.cached_enabled_outcome(&cache_key) {
|
||||
return outcome;
|
||||
return self.project_plugins_for_auth(outcome);
|
||||
}
|
||||
|
||||
let Ok(_load_permit) = self.enabled_outcome_load_semaphore.acquire().await else {
|
||||
@@ -425,7 +455,7 @@ impl PluginsManager {
|
||||
return PluginLoadOutcome::default();
|
||||
};
|
||||
if !force_reload && let Some(outcome) = self.cached_enabled_outcome(&cache_key) {
|
||||
return outcome;
|
||||
return self.project_plugins_for_auth(outcome);
|
||||
}
|
||||
let cache_generation = self.enabled_outcome_cache_generation();
|
||||
let outcome = load_plugins_from_layer_stack(
|
||||
@@ -438,7 +468,11 @@ impl PluginsManager {
|
||||
.await;
|
||||
log_plugin_load_errors(&outcome);
|
||||
self.cache_enabled_outcome_if_current(cache_generation, cache_key, outcome.clone());
|
||||
outcome
|
||||
self.project_plugins_for_auth(outcome)
|
||||
}
|
||||
|
||||
fn project_plugins_for_auth(&self, outcome: PluginLoadOutcome) -> PluginLoadOutcome {
|
||||
project_plugin_load_outcome_for_auth(outcome, self.auth_mode())
|
||||
}
|
||||
|
||||
pub fn clear_cache(&self) {
|
||||
@@ -468,14 +502,15 @@ impl PluginsManager {
|
||||
if !config.plugins_enabled {
|
||||
return PluginLoadOutcome::default();
|
||||
}
|
||||
load_plugins_from_layer_stack(
|
||||
let outcome = load_plugins_from_layer_stack(
|
||||
config_layer_stack,
|
||||
self.remote_installed_plugin_configs(),
|
||||
&self.store,
|
||||
self.restriction_product,
|
||||
config.remote_plugin_enabled,
|
||||
)
|
||||
.await
|
||||
.await;
|
||||
self.project_plugins_for_auth(outcome)
|
||||
}
|
||||
|
||||
/// Resolve plugin hooks for a config layer stack without loading other plugin capabilities.
|
||||
@@ -1190,6 +1225,7 @@ impl PluginsManager {
|
||||
disabled_skill_paths: HashSet::new(),
|
||||
hooks: Vec::new(),
|
||||
apps: Vec::new(),
|
||||
app_category_by_id: HashMap::new(),
|
||||
mcp_server_names: Vec::new(),
|
||||
details_unavailable_reason: Some(
|
||||
PluginDetailsUnavailableReason::InstallRequiredForRemoteSource,
|
||||
@@ -1256,7 +1292,12 @@ impl PluginsManager {
|
||||
event_name: hook.event_name,
|
||||
})
|
||||
.collect();
|
||||
let apps = load_plugin_apps(source_path.as_path()).await;
|
||||
let app_metadata = load_plugin_app_metadata(source_path.as_path()).await;
|
||||
let apps = app_metadata.iter().map(|app| app.id.clone()).collect();
|
||||
let app_category_by_id = app_metadata
|
||||
.into_iter()
|
||||
.filter_map(|app| app.category.map(|category| (app.id.0, category)))
|
||||
.collect();
|
||||
let mut mcp_server_names = load_plugin_mcp_servers(source_path.as_path())
|
||||
.await
|
||||
.into_keys()
|
||||
@@ -1279,6 +1320,7 @@ impl PluginsManager {
|
||||
disabled_skill_paths: resolved_skills.disabled_skill_paths,
|
||||
hooks,
|
||||
apps,
|
||||
app_category_by_id,
|
||||
mcp_server_names,
|
||||
details_unavailable_reason: None,
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::test_support::load_plugins_config as load_plugins_config_input;
|
||||
use crate::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha;
|
||||
use crate::test_support::write_file;
|
||||
use crate::test_support::write_openai_curated_marketplace;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_config::AppToolApproval;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
@@ -47,6 +48,21 @@ use wiremock::matchers::query_param;
|
||||
|
||||
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
|
||||
|
||||
#[test]
|
||||
fn plugins_manager_tracks_auth_mode() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let manager = PluginsManager::new(tmp.path().to_path_buf());
|
||||
|
||||
assert_eq!(manager.auth_mode(), None);
|
||||
assert!(manager.set_auth_mode(Some(AuthMode::ApiKey)));
|
||||
assert_eq!(manager.auth_mode(), Some(AuthMode::ApiKey));
|
||||
assert!(!manager.set_auth_mode(Some(AuthMode::ApiKey)));
|
||||
assert!(manager.set_auth_mode(Some(AuthMode::ChatgptAuthTokens)));
|
||||
assert_eq!(manager.auth_mode(), Some(AuthMode::ChatgptAuthTokens));
|
||||
assert!(manager.set_auth_mode(/*auth_mode*/ None));
|
||||
assert_eq!(manager.auth_mode(), None);
|
||||
}
|
||||
|
||||
fn write_plugin_with_version(
|
||||
root: &Path,
|
||||
dir_name: &str,
|
||||
|
||||
@@ -178,6 +178,7 @@ pub struct RemoteAppTemplate {
|
||||
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>,
|
||||
@@ -458,6 +459,8 @@ struct RemoteAppTemplateResponse {
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
category: Option<String>,
|
||||
#[serde(default)]
|
||||
canonical_connector_id: Option<String>,
|
||||
#[serde(default)]
|
||||
logo_url: Option<String>,
|
||||
@@ -1059,6 +1062,7 @@ async fn build_remote_plugin_detail(
|
||||
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,
|
||||
|
||||
@@ -26,6 +26,7 @@ codex-protocol = { workspace = true }
|
||||
codex-skills = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-output-truncation = { workspace = true }
|
||||
codex-utils-path-uri = { workspace = true }
|
||||
codex-utils-plugins = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
dunce = { workspace = true }
|
||||
|
||||
@@ -13,6 +13,7 @@ use codex_exec_server::LOCAL_FS;
|
||||
use codex_otel::SessionTelemetry;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
@@ -75,10 +76,21 @@ pub async fn build_skill_injections(
|
||||
let fs = loaded_skills
|
||||
.and_then(|outcome| outcome.file_system_for_skill(skill))
|
||||
.unwrap_or_else(|| Arc::clone(&LOCAL_FS));
|
||||
match fs
|
||||
.read_file_text(&skill.path_to_skills_md, /*sandbox*/ None)
|
||||
.await
|
||||
{
|
||||
// Skill metadata may point at a file that is absent, but a path the host
|
||||
// cannot represent means the configured skill cannot be loaded correctly.
|
||||
let path = match PathUri::from_abs_path(&skill.path_to_skills_md) {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
emit_skill_injected_metric(otel, skill, "error");
|
||||
result.warnings.push(format!(
|
||||
"Failed to load skill {name} at {path}: {err:#}",
|
||||
name = skill.name,
|
||||
path = skill.path_to_skills_md.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match fs.read_file_text(&path, /*sandbox*/ None).await {
|
||||
Ok(contents) => {
|
||||
emit_skill_injected_metric(otel, skill, "ok");
|
||||
invocations.push(SkillInvocation {
|
||||
|
||||
@@ -19,6 +19,7 @@ use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use codex_utils_plugins::PluginSkillRoot;
|
||||
use codex_utils_plugins::plugin_namespace_for_skill_path;
|
||||
use dirs::home_dir;
|
||||
@@ -374,7 +375,17 @@ async fn repo_agents_skill_roots(
|
||||
let mut roots = Vec::new();
|
||||
for dir in dirs {
|
||||
let agents_skills = dir.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME);
|
||||
match fs.get_metadata(&agents_skills, /*sandbox*/ None).await {
|
||||
let agents_skills_uri = match PathUri::from_abs_path(&agents_skills) {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"failed to convert repo skills root {} to URI: {err:#}",
|
||||
agents_skills.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match fs.get_metadata(&agents_skills_uri, /*sandbox*/ None).await {
|
||||
Ok(metadata) if metadata.is_directory => roots.push(SkillRoot {
|
||||
path: agents_skills,
|
||||
scope: SkillScope::Repo,
|
||||
@@ -429,7 +440,17 @@ async fn find_project_root(
|
||||
for ancestor in cwd.ancestors() {
|
||||
for marker in project_root_markers {
|
||||
let marker_path = ancestor.join(marker);
|
||||
match fs.get_metadata(&marker_path, /*sandbox*/ None).await {
|
||||
let marker_path_uri = match PathUri::from_abs_path(&marker_path) {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"failed to convert project root marker {} to URI: {err:#}",
|
||||
marker_path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match fs.get_metadata(&marker_path_uri, /*sandbox*/ None).await {
|
||||
Ok(_) => return ancestor,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(err) => {
|
||||
@@ -475,8 +496,12 @@ async fn canonicalize_for_skill_identity(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
path: &AbsolutePathBuf,
|
||||
) -> AbsolutePathBuf {
|
||||
fs.canonicalize(path, /*sandbox*/ None)
|
||||
let Ok(path_uri) = PathUri::from_abs_path(path) else {
|
||||
return path.clone();
|
||||
};
|
||||
fs.canonicalize(&path_uri, /*sandbox*/ None)
|
||||
.await
|
||||
.and_then(|path| path.to_abs_path())
|
||||
.unwrap_or_else(|_| path.clone())
|
||||
}
|
||||
|
||||
@@ -494,7 +519,17 @@ async fn discover_skills_under_root(
|
||||
None => None,
|
||||
};
|
||||
|
||||
match fs.get_metadata(&root, /*sandbox*/ None).await {
|
||||
let root_uri = match PathUri::from_abs_path(&root) {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"failed to convert skills root {} to URI: {err:#}",
|
||||
root.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
match fs.get_metadata(&root_uri, /*sandbox*/ None).await {
|
||||
Ok(metadata) if metadata.is_directory => {}
|
||||
Ok(_) => return,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return,
|
||||
@@ -536,7 +571,17 @@ async fn discover_skills_under_root(
|
||||
let mut truncated_by_dir_limit = false;
|
||||
|
||||
while let Some((dir, depth)) = queue.pop_front() {
|
||||
let entries = match fs.read_directory(&dir, /*sandbox*/ None).await {
|
||||
let dir_uri = match PathUri::from_abs_path(&dir) {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"failed to convert skills dir {} to URI: {e:#}",
|
||||
dir.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let entries = match fs.read_directory(&dir_uri, /*sandbox*/ None).await {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
error!("failed to read skills dir {}: {e:#}", dir.display());
|
||||
@@ -551,7 +596,17 @@ async fn discover_skills_under_root(
|
||||
}
|
||||
|
||||
let path = dir.join(&file_name);
|
||||
let metadata = match fs.get_metadata(&path, /*sandbox*/ None).await {
|
||||
let path_uri = match PathUri::from_abs_path(&path) {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"failed to convert skills path {} to URI: {e:#}",
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let metadata = match fs.get_metadata(&path_uri, /*sandbox*/ None).await {
|
||||
Ok(metadata) => metadata,
|
||||
Err(e) => {
|
||||
error!("failed to stat skills path {}: {e:#}", path.display());
|
||||
@@ -563,7 +618,7 @@ async fn discover_skills_under_root(
|
||||
if !follow_symlinks {
|
||||
continue;
|
||||
}
|
||||
match fs.read_directory(&path, /*sandbox*/ None).await {
|
||||
match fs.read_directory(&path_uri, /*sandbox*/ None).await {
|
||||
Ok(_) => {
|
||||
let resolved_dir = canonicalize_for_skill_identity(fs, &path).await;
|
||||
enqueue_dir(
|
||||
@@ -635,8 +690,9 @@ async fn parse_skill_file(
|
||||
plugin_id: Option<&str>,
|
||||
plugin_root: Option<&AbsolutePathBuf>,
|
||||
) -> Result<SkillMetadata, SkillParseError> {
|
||||
let path_uri = PathUri::from_abs_path(path).map_err(SkillParseError::Read)?;
|
||||
let contents = fs
|
||||
.read_file_text(path, /*sandbox*/ None)
|
||||
.read_file_text(&path_uri, /*sandbox*/ None)
|
||||
.await
|
||||
.map_err(SkillParseError::Read)?;
|
||||
|
||||
@@ -730,7 +786,18 @@ async fn load_skill_metadata(
|
||||
let metadata_path = skill_dir
|
||||
.join(SKILLS_METADATA_DIR)
|
||||
.join(SKILLS_METADATA_FILENAME);
|
||||
match fs.get_metadata(&metadata_path, /*sandbox*/ None).await {
|
||||
let metadata_path_uri = match PathUri::from_abs_path(&metadata_path) {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"ignoring {path}: failed to convert {label} path to URI: {error}",
|
||||
path = metadata_path.display(),
|
||||
label = SKILLS_METADATA_FILENAME
|
||||
);
|
||||
return LoadedSkillMetadata::default();
|
||||
}
|
||||
};
|
||||
match fs.get_metadata(&metadata_path_uri, /*sandbox*/ None).await {
|
||||
Ok(metadata) if metadata.is_file => {}
|
||||
Ok(_) => return LoadedSkillMetadata::default(),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {
|
||||
@@ -746,7 +813,10 @@ async fn load_skill_metadata(
|
||||
}
|
||||
}
|
||||
|
||||
let contents = match fs.read_file_text(&metadata_path, /*sandbox*/ None).await {
|
||||
let contents = match fs
|
||||
.read_file_text(&metadata_path_uri, /*sandbox*/ None)
|
||||
.await
|
||||
{
|
||||
Ok(contents) => contents,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
|
||||
@@ -9,6 +9,7 @@ use codex_exec_server::LOCAL_FS;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SkillMetadata {
|
||||
@@ -152,8 +153,8 @@ impl HostLoadedSkills {
|
||||
.outcome
|
||||
.file_system_for_skill(skill)
|
||||
.unwrap_or_else(|| Arc::clone(&LOCAL_FS));
|
||||
fs.read_file_text(&skill.path_to_skills_md, /*sandbox*/ None)
|
||||
.await
|
||||
let path = PathUri::from_abs_path(&skill.path_to_skills_md)?;
|
||||
fs.read_file_text(&path, /*sandbox*/ None).await
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,17 +22,17 @@ pub const SKILL_DESCRIPTION_TRUNCATED_WARNING: &str = "Skill descriptions were s
|
||||
pub const SKILL_DESCRIPTION_TRUNCATED_WARNING_WITH_PERCENT: &str = "Skill descriptions were shortened to fit the 2% skills context budget. Codex can still see every skill, but some descriptions are shorter. Disable unused skills or plugins to leave more room for the rest.";
|
||||
pub const SKILL_DESCRIPTIONS_REMOVED_WARNING_PREFIX: &str =
|
||||
"Exceeded skills context budget. All skill descriptions were removed and";
|
||||
pub const SKILLS_INTRO_WITH_ABSOLUTE_PATHS: &str = "A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.";
|
||||
pub const SKILLS_INTRO_WITH_ABSOLUTE_PATHS: &str = "A skill is a set of instructions provided through a `SKILL.md` source. Below is the list of skills that can be used. Each entry includes a name, description, and source locator. `file` locators are on the host filesystem, `environment resource` locators are owned by an execution environment, `orchestrator resource` locators are opaque non-filesystem resources, and `custom resource` locators use their provider's access mechanism.";
|
||||
pub const SKILLS_INTRO_WITH_ALIASES: &str = "A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and a short path that can be expanded into an absolute path using the skill roots table.";
|
||||
pub const SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS: &str = r###"- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.
|
||||
pub const SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS: &str = r###"- Discovery: The list above is the skills available in this session (name + description + source locator). `file` entries live on the host filesystem, `environment resource` entries are owned by their execution environment, `orchestrator resource` entries must be accessed through `skills.list` and `skills.read`, and `custom resource` entries use their provider's access mechanism.
|
||||
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
|
||||
- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
|
||||
- Missing/blocked: If a named skill isn't in the list or its source can't be read, say so briefly and continue with the best fallback.
|
||||
- How to use a skill (progressive disclosure):
|
||||
1) After deciding to use a skill, the main agent must open and read its `SKILL.md` completely before taking task actions. If a read is truncated or paginated, continue until EOF.
|
||||
2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed.
|
||||
3) If `SKILL.md` points to extra folders such as `references/`, use its routing instructions to identify the files required for the task. The main agent must read each required instruction or reference file itself before acting on it. Do not delegate reading, summarizing, or interpreting skill instructions to a subagent. Subagents may still perform task work when the selected skill allows it.
|
||||
4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
|
||||
5) If `assets/` or templates exist, reuse them instead of recreating from scratch.
|
||||
1) After deciding to use a skill, the main agent must read its `SKILL.md` completely before taking task actions. For a `file` entry, open the listed path. For an `environment resource`, use the filesystem of the owning environment. For an `orchestrator resource`, call `skills.list` with `{"authority":{"kind":"orchestrator"}}`, select the matching package, and pass its `main_resource` to `skills.read`. If a read is truncated or paginated, continue until EOF.
|
||||
2) When `SKILL.md` references another resource, use the same access mechanism. Resolve relative paths against a filesystem-backed skill directory. For orchestrator skills, pass the exact referenced resource identifier with the same authority and package to `skills.read`; do not treat `skill://` identifiers as filesystem paths.
|
||||
3) If `SKILL.md` points to extra folders such as `references/`, use its routing instructions to identify the resources required for the task. The main agent must read each required instruction or reference file itself before acting on it. Do not delegate reading, summarizing, or interpreting skill instructions to a subagent. Subagents may still perform task work when the selected skill allows it.
|
||||
4) For filesystem-backed skills, prefer running or patching provided scripts instead of retyping large code blocks. For orchestrator skills, use `skills.read` and the available tools; do not invent a local path.
|
||||
5) Reuse provided assets or templates through the same source access mechanism instead of recreating them.
|
||||
- Coordination and sequencing:
|
||||
- If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
|
||||
- Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
|
||||
|
||||
@@ -71,6 +71,7 @@ codex-utils-image = { workspace = true }
|
||||
codex-utils-home-dir = { workspace = true }
|
||||
codex-utils-output-truncation = { workspace = true }
|
||||
codex-utils-path = { workspace = true }
|
||||
codex-utils-path-uri = { workspace = true }
|
||||
codex-utils-plugins = { workspace = true }
|
||||
codex-utils-pty = { workspace = true }
|
||||
codex-utils-string = { workspace = true }
|
||||
@@ -135,6 +136,7 @@ codex-shell-escalation = { workspace = true }
|
||||
assert_cmd = { workspace = true }
|
||||
assert_matches = { workspace = true }
|
||||
codex-image-generation-extension = { workspace = true }
|
||||
codex-home = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
codex-test-binary-support = { workspace = true }
|
||||
codex-utils-cargo-bin = { workspace = true }
|
||||
|
||||
@@ -576,6 +576,9 @@
|
||||
"request_rule": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"resize_all_images": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"responses_websockets": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -621,6 +624,9 @@
|
||||
"terminal_visualization_instructions": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"token_budget": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tool_call_mcp_elicitation": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -4708,6 +4714,9 @@
|
||||
"request_rule": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"resize_all_images": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"responses_websockets": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -4753,6 +4762,9 @@
|
||||
"terminal_visualization_instructions": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"token_budget": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tool_call_mcp_elicitation": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
+229
-260
@@ -21,11 +21,12 @@ use codex_config::ConfigLayerStackOrdering;
|
||||
use codex_config::default_project_root_markers;
|
||||
use codex_config::merge_toml_values;
|
||||
use codex_config::project_root_markers_from_config;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_extension_api::UserInstructions;
|
||||
use codex_features::Feature;
|
||||
use codex_prompts::HIERARCHICAL_AGENTS_MESSAGE;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use std::io;
|
||||
use toml::Value as TomlValue;
|
||||
use tracing::error;
|
||||
@@ -39,276 +40,220 @@ pub const LOCAL_AGENTS_MD_FILENAME: &str = "AGENTS.override.md";
|
||||
/// concatenated with the following separator.
|
||||
const AGENTS_MD_SEPARATOR: &str = "\n\n--- project-doc ---\n\n";
|
||||
|
||||
/// Resolves AGENTS.md files into model-visible user instructions and source
|
||||
/// paths.
|
||||
pub struct AgentsMdManager<'a> {
|
||||
config: &'a Config,
|
||||
}
|
||||
|
||||
impl<'a> AgentsMdManager<'a> {
|
||||
pub fn new(config: &'a Config) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
pub(crate) async fn load_global_instructions(
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
codex_dir: Option<&AbsolutePathBuf>,
|
||||
startup_warnings: &mut Vec<String>,
|
||||
) -> Option<LoadedAgentsMd> {
|
||||
let base = codex_dir?;
|
||||
for candidate in [LOCAL_AGENTS_MD_FILENAME, DEFAULT_AGENTS_MD_FILENAME] {
|
||||
let path = base.join(candidate);
|
||||
let data = match fs.read_file(&path, /*sandbox*/ None).await {
|
||||
Ok(data) => data,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
|
||||
Err(err) if err.kind() == io::ErrorKind::IsADirectory => continue,
|
||||
Err(err) => {
|
||||
startup_warnings.push(format!(
|
||||
"Failed to read global AGENTS.md instructions from `{}`: {err}",
|
||||
path.display()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
warn_invalid_utf8(&path, &data, "Global", startup_warnings);
|
||||
let contents = String::from_utf8_lossy(&data);
|
||||
let trimmed = contents.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(LoadedAgentsMd::new_user(trimmed.to_string(), path));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Combines configured user instructions and AGENTS.md content into a
|
||||
/// single model-visible instruction string.
|
||||
pub(crate) async fn user_instructions(
|
||||
&self,
|
||||
environment: &Environment,
|
||||
startup_warnings: &mut Vec<String>,
|
||||
) -> Option<LoadedAgentsMd> {
|
||||
let fs = environment.get_filesystem();
|
||||
self.user_instructions_with_fs(fs.as_ref(), startup_warnings)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn user_instructions_with_fs(
|
||||
&self,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
startup_warnings: &mut Vec<String>,
|
||||
) -> Option<LoadedAgentsMd> {
|
||||
let agents_md_docs = self.read_agents_md(fs, startup_warnings).await;
|
||||
|
||||
let mut loaded = self.config.user_instructions.clone().unwrap_or_default();
|
||||
|
||||
match agents_md_docs {
|
||||
/// Loads project AGENTS.md content and combines it with host-provided user
|
||||
/// instructions.
|
||||
pub(crate) async fn load_project_instructions(
|
||||
config: &mut Config,
|
||||
user_instructions: Option<UserInstructions>,
|
||||
fs: Option<&dyn ExecutorFileSystem>,
|
||||
) -> Option<LoadedAgentsMd> {
|
||||
let mut loaded = LoadedAgentsMd::from_user_instructions(user_instructions);
|
||||
if let Some(fs) = fs {
|
||||
match read_agents_md(config, fs).await {
|
||||
Ok(Some(docs)) => loaded.entries.extend(docs.entries),
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
error!("error trying to find AGENTS.md docs: {e:#}");
|
||||
}
|
||||
};
|
||||
|
||||
if self.config.features.enabled(Feature::ChildAgentsMd) {
|
||||
loaded.entries.push(InstructionEntry {
|
||||
contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(),
|
||||
provenance: InstructionProvenance::Internal,
|
||||
});
|
||||
}
|
||||
|
||||
(!loaded.is_empty()).then_some(loaded)
|
||||
}
|
||||
|
||||
/// Attempt to locate and load AGENTS.md documentation.
|
||||
///
|
||||
/// On success returns `Ok(Some(loaded))` where `loaded` contains every
|
||||
/// discovered doc. If no documentation file is found the function returns
|
||||
/// `Ok(None)`. Unexpected I/O failures bubble up as `Err` so callers can
|
||||
/// decide how to handle them.
|
||||
async fn read_agents_md(
|
||||
&self,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
startup_warnings: &mut Vec<String>,
|
||||
) -> io::Result<Option<LoadedAgentsMd>> {
|
||||
let max_total = self.config.project_doc_max_bytes;
|
||||
if config.features.enabled(Feature::ChildAgentsMd) {
|
||||
loaded.entries.push(InstructionEntry {
|
||||
contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(),
|
||||
provenance: InstructionProvenance::Internal,
|
||||
});
|
||||
}
|
||||
|
||||
if max_total == 0 {
|
||||
return Ok(None);
|
||||
(!loaded.is_empty()).then_some(loaded)
|
||||
}
|
||||
|
||||
/// Attempt to locate and load AGENTS.md documentation.
|
||||
///
|
||||
/// On success returns `Ok(Some(loaded))` where `loaded` contains every
|
||||
/// discovered doc. If no documentation file is found the function returns
|
||||
/// `Ok(None)`. Unexpected I/O failures bubble up as `Err` so callers can
|
||||
/// decide how to handle them.
|
||||
async fn read_agents_md(
|
||||
config: &mut Config,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
) -> io::Result<Option<LoadedAgentsMd>> {
|
||||
let max_total = config.project_doc_max_bytes;
|
||||
|
||||
if max_total == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let paths = agents_md_paths(config, fs).await?;
|
||||
if paths.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut remaining: u64 = max_total as u64;
|
||||
let mut loaded = LoadedAgentsMd::default();
|
||||
|
||||
for p in paths {
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
let paths = self.agents_md_paths(fs).await?;
|
||||
if paths.is_empty() {
|
||||
return Ok(None);
|
||||
let path_uri = PathUri::from_abs_path(&p)?;
|
||||
match fs.get_metadata(&path_uri, /*sandbox*/ None).await {
|
||||
Ok(metadata) if !metadata.is_file => continue,
|
||||
Ok(_) => {}
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
|
||||
let mut remaining: u64 = max_total as u64;
|
||||
let mut loaded = LoadedAgentsMd::default();
|
||||
let mut data = match fs.read_file(&path_uri, /*sandbox*/ None).await {
|
||||
Ok(data) => data,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
warn_invalid_utf8(&p, &data, "Project", &mut config.startup_warnings);
|
||||
|
||||
for p in paths {
|
||||
if remaining == 0 {
|
||||
let size = data.len() as u64;
|
||||
if size > remaining {
|
||||
data.truncate(remaining as usize);
|
||||
}
|
||||
|
||||
if size > remaining {
|
||||
tracing::warn!(
|
||||
"Project doc `{}` exceeds remaining budget ({} bytes) - truncating.",
|
||||
p.display(),
|
||||
remaining,
|
||||
);
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(&data).to_string();
|
||||
if !text.trim().is_empty() {
|
||||
loaded.entries.push(InstructionEntry {
|
||||
contents: text,
|
||||
provenance: InstructionProvenance::Project(p),
|
||||
});
|
||||
remaining = remaining.saturating_sub(data.len() as u64);
|
||||
}
|
||||
}
|
||||
|
||||
if loaded.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(loaded))
|
||||
}
|
||||
}
|
||||
|
||||
/// Discovers AGENTS.md files from the project root to the current working
|
||||
/// directory, inclusive. Symlinks are allowed.
|
||||
async fn agents_md_paths(
|
||||
config: &Config,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
) -> io::Result<Vec<AbsolutePathBuf>> {
|
||||
let dir = config.cwd.clone();
|
||||
|
||||
let mut merged = TomlValue::Table(toml::map::Map::new());
|
||||
for layer in config.config_layer_stack.get_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ false,
|
||||
) {
|
||||
if matches!(layer.name, ConfigLayerSource::Project { .. }) {
|
||||
continue;
|
||||
}
|
||||
merge_toml_values(&mut merged, &layer.config);
|
||||
}
|
||||
let project_root_markers = match project_root_markers_from_config(&merged) {
|
||||
Ok(Some(markers)) => markers,
|
||||
Ok(None) => default_project_root_markers(),
|
||||
Err(err) => {
|
||||
tracing::warn!("invalid project_root_markers: {err}");
|
||||
default_project_root_markers()
|
||||
}
|
||||
};
|
||||
let mut project_root = None;
|
||||
if !project_root_markers.is_empty() {
|
||||
for ancestor in dir.ancestors() {
|
||||
for marker in &project_root_markers {
|
||||
let marker_path = ancestor.join(marker);
|
||||
let marker_path_uri = PathUri::from_abs_path(&marker_path)?;
|
||||
let marker_exists = match fs.get_metadata(&marker_path_uri, /*sandbox*/ None).await
|
||||
{
|
||||
Ok(_) => true,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => false,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
if marker_exists {
|
||||
project_root = Some(ancestor.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
if project_root.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match fs.get_metadata(&p, /*sandbox*/ None).await {
|
||||
Ok(metadata) if !metadata.is_file => continue,
|
||||
let search_dirs: Vec<AbsolutePathBuf> = if let Some(root) = project_root {
|
||||
let mut dirs = Vec::new();
|
||||
let mut cursor = dir.clone();
|
||||
loop {
|
||||
dirs.push(cursor.clone());
|
||||
if cursor == root {
|
||||
break;
|
||||
}
|
||||
let Some(parent) = cursor.parent() else {
|
||||
break;
|
||||
};
|
||||
cursor = parent;
|
||||
}
|
||||
dirs.reverse();
|
||||
dirs
|
||||
} else {
|
||||
vec![dir]
|
||||
};
|
||||
|
||||
let mut found: Vec<AbsolutePathBuf> = Vec::new();
|
||||
let candidate_filenames = candidate_filenames(config);
|
||||
for d in search_dirs {
|
||||
for name in &candidate_filenames {
|
||||
let candidate = d.join(name);
|
||||
let candidate_uri = PathUri::from_abs_path(&candidate)?;
|
||||
match fs.get_metadata(&candidate_uri, /*sandbox*/ None).await {
|
||||
Ok(md) if md.is_file => {
|
||||
found.push(candidate);
|
||||
break;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
|
||||
let mut data = match fs.read_file(&p, /*sandbox*/ None).await {
|
||||
Ok(data) => data,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
warn_invalid_utf8(&p, &data, "Project", startup_warnings);
|
||||
|
||||
let size = data.len() as u64;
|
||||
if size > remaining {
|
||||
data.truncate(remaining as usize);
|
||||
}
|
||||
|
||||
if size > remaining {
|
||||
tracing::warn!(
|
||||
"Project doc `{}` exceeds remaining budget ({} bytes) - truncating.",
|
||||
p.display(),
|
||||
remaining,
|
||||
);
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(&data).to_string();
|
||||
if !text.trim().is_empty() {
|
||||
loaded.entries.push(InstructionEntry {
|
||||
contents: text,
|
||||
provenance: InstructionProvenance::Project(p),
|
||||
});
|
||||
remaining = remaining.saturating_sub(data.len() as u64);
|
||||
}
|
||||
}
|
||||
|
||||
if loaded.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(loaded))
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover the list of AGENTS.md files using the same search rules as
|
||||
/// `read_agents_md`, but return the file paths instead of concatenated
|
||||
/// contents. The list is ordered from project root to the current working
|
||||
/// directory (inclusive). Symlinks are allowed. When `project_doc_max_bytes`
|
||||
/// is zero, returns an empty list.
|
||||
async fn agents_md_paths(
|
||||
&self,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
) -> io::Result<Vec<AbsolutePathBuf>> {
|
||||
if self.config.project_doc_max_bytes == 0 {
|
||||
return Ok(Vec::new());
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
fn candidate_filenames(config: &Config) -> Vec<&str> {
|
||||
let mut names: Vec<&str> = Vec::with_capacity(2 + config.project_doc_fallback_filenames.len());
|
||||
names.push(LOCAL_AGENTS_MD_FILENAME);
|
||||
names.push(DEFAULT_AGENTS_MD_FILENAME);
|
||||
for candidate in &config.project_doc_fallback_filenames {
|
||||
let candidate = candidate.as_str();
|
||||
if candidate.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let dir = self.config.cwd.clone();
|
||||
|
||||
let mut merged = TomlValue::Table(toml::map::Map::new());
|
||||
for layer in self.config.config_layer_stack.get_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ false,
|
||||
) {
|
||||
if matches!(layer.name, ConfigLayerSource::Project { .. }) {
|
||||
continue;
|
||||
}
|
||||
merge_toml_values(&mut merged, &layer.config);
|
||||
if !names.contains(&candidate) {
|
||||
names.push(candidate);
|
||||
}
|
||||
let project_root_markers = match project_root_markers_from_config(&merged) {
|
||||
Ok(Some(markers)) => markers,
|
||||
Ok(None) => default_project_root_markers(),
|
||||
Err(err) => {
|
||||
tracing::warn!("invalid project_root_markers: {err}");
|
||||
default_project_root_markers()
|
||||
}
|
||||
};
|
||||
let mut project_root = None;
|
||||
if !project_root_markers.is_empty() {
|
||||
for ancestor in dir.ancestors() {
|
||||
for marker in &project_root_markers {
|
||||
let marker_path = ancestor.join(marker);
|
||||
let marker_exists = match fs.get_metadata(&marker_path, /*sandbox*/ None).await
|
||||
{
|
||||
Ok(_) => true,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => false,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
if marker_exists {
|
||||
project_root = Some(ancestor.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
if project_root.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let search_dirs: Vec<AbsolutePathBuf> = if let Some(root) = project_root {
|
||||
let mut dirs = Vec::new();
|
||||
let mut cursor = dir.clone();
|
||||
loop {
|
||||
dirs.push(cursor.clone());
|
||||
if cursor == root {
|
||||
break;
|
||||
}
|
||||
let Some(parent) = cursor.parent() else {
|
||||
break;
|
||||
};
|
||||
cursor = parent;
|
||||
}
|
||||
dirs.reverse();
|
||||
dirs
|
||||
} else {
|
||||
vec![dir]
|
||||
};
|
||||
|
||||
let mut found: Vec<AbsolutePathBuf> = Vec::new();
|
||||
let candidate_filenames = self.candidate_filenames();
|
||||
for d in search_dirs {
|
||||
for name in &candidate_filenames {
|
||||
let candidate = d.join(name);
|
||||
match fs.get_metadata(&candidate, /*sandbox*/ None).await {
|
||||
Ok(md) if md.is_file => {
|
||||
found.push(candidate);
|
||||
break;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
fn candidate_filenames(&self) -> Vec<&str> {
|
||||
let mut names: Vec<&str> =
|
||||
Vec::with_capacity(2 + self.config.project_doc_fallback_filenames.len());
|
||||
names.push(LOCAL_AGENTS_MD_FILENAME);
|
||||
names.push(DEFAULT_AGENTS_MD_FILENAME);
|
||||
for candidate in &self.config.project_doc_fallback_filenames {
|
||||
let candidate = candidate.as_str();
|
||||
if candidate.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !names.contains(&candidate) {
|
||||
names.push(candidate);
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
/// Model-visible instructions loaded from AGENTS.md files and internal
|
||||
/// guidance.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct LoadedAgentsMd {
|
||||
/// Host-provided user instructions.
|
||||
user_instructions: Option<UserInstructions>,
|
||||
|
||||
/// Ordered instructions and their provenance.
|
||||
entries: Vec<InstructionEntry>,
|
||||
}
|
||||
@@ -320,10 +265,19 @@ impl LoadedAgentsMd {
|
||||
return Self::default();
|
||||
}
|
||||
Self {
|
||||
entries: vec![InstructionEntry {
|
||||
contents,
|
||||
provenance: InstructionProvenance::User(path),
|
||||
}],
|
||||
user_instructions: Some(UserInstructions {
|
||||
text: contents,
|
||||
source: path,
|
||||
}),
|
||||
entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_user_instructions(user_instructions: Option<UserInstructions>) -> Self {
|
||||
Self {
|
||||
user_instructions: user_instructions
|
||||
.filter(|instructions| !instructions.text.trim().is_empty()),
|
||||
entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,6 +291,7 @@ impl LoadedAgentsMd {
|
||||
return Self::default();
|
||||
}
|
||||
Self {
|
||||
user_instructions: None,
|
||||
entries: vec![InstructionEntry {
|
||||
contents,
|
||||
provenance: InstructionProvenance::Internal,
|
||||
@@ -345,40 +300,57 @@ impl LoadedAgentsMd {
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.entries
|
||||
.iter()
|
||||
.all(|entry| entry.contents.trim().is_empty())
|
||||
self.user_instructions.is_none()
|
||||
&& self
|
||||
.entries
|
||||
.iter()
|
||||
.all(|entry| entry.contents.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Returns the concatenated model-visible instruction text.
|
||||
pub fn text(&self) -> String {
|
||||
let mut output = String::new();
|
||||
let mut previous_provenance: Option<&InstructionProvenance> = None;
|
||||
let mut has_previous = false;
|
||||
let mut previous_was_project = false;
|
||||
if let Some(instructions) = &self.user_instructions {
|
||||
output.push_str(&instructions.text);
|
||||
has_previous = true;
|
||||
}
|
||||
for entry in &self.entries {
|
||||
if let Some(previous_provenance) = previous_provenance {
|
||||
let is_project = matches!(&entry.provenance, InstructionProvenance::Project(_));
|
||||
if has_previous {
|
||||
// The project-doc marker tells the model where workspace-scoped
|
||||
// instructions begin, so it is only needed on the transition
|
||||
// from user or internal instructions to project instructions.
|
||||
let separator = match (previous_provenance, &entry.provenance) {
|
||||
(
|
||||
InstructionProvenance::User(_) | InstructionProvenance::Internal,
|
||||
InstructionProvenance::Project(_),
|
||||
) => AGENTS_MD_SEPARATOR,
|
||||
_ => "\n\n",
|
||||
let separator = if is_project && !previous_was_project {
|
||||
AGENTS_MD_SEPARATOR
|
||||
} else {
|
||||
"\n\n"
|
||||
};
|
||||
output.push_str(separator);
|
||||
}
|
||||
output.push_str(&entry.contents);
|
||||
previous_provenance = Some(&entry.provenance);
|
||||
has_previous = true;
|
||||
previous_was_project = is_project;
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
/// Returns the host-provided user instructions.
|
||||
pub(crate) fn user_instructions(&self) -> Option<&UserInstructions> {
|
||||
self.user_instructions.as_ref()
|
||||
}
|
||||
|
||||
/// Returns the AGENTS.md files that supplied instruction entries.
|
||||
pub fn sources(&self) -> impl Iterator<Item = &AbsolutePathBuf> {
|
||||
self.entries
|
||||
self.user_instructions
|
||||
.iter()
|
||||
.filter_map(|entry| entry.provenance.path())
|
||||
.map(|instructions| &instructions.source)
|
||||
.chain(
|
||||
self.entries
|
||||
.iter()
|
||||
.filter_map(|entry| entry.provenance.path()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,9 +366,6 @@ struct InstructionEntry {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum InstructionProvenance {
|
||||
/// User-level instructions, normally loaded from CODEX_HOME.
|
||||
User(AbsolutePathBuf),
|
||||
|
||||
/// Workspace instructions discovered from project AGENTS.md files.
|
||||
Project(AbsolutePathBuf),
|
||||
|
||||
@@ -407,7 +376,7 @@ enum InstructionProvenance {
|
||||
impl InstructionProvenance {
|
||||
fn path(&self) -> Option<&AbsolutePathBuf> {
|
||||
match self {
|
||||
Self::User(path) | Self::Project(path) => Some(path),
|
||||
Self::Project(path) => Some(path),
|
||||
Self::Internal => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,172 @@
|
||||
use super::*;
|
||||
use crate::config::ConfigBuilder;
|
||||
use async_trait::async_trait;
|
||||
use codex_config::ConfigLayerEntry;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::ConfigRequirements;
|
||||
use codex_config::ConfigRequirementsToml;
|
||||
use codex_exec_server::CopyOptions;
|
||||
use codex_exec_server::CreateDirectoryOptions;
|
||||
use codex_exec_server::FileMetadata;
|
||||
use codex_exec_server::FileSystemSandboxContext;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_exec_server::ReadDirectoryEntry;
|
||||
use codex_exec_server::RemoveOptions;
|
||||
use codex_extension_api::UserInstructions;
|
||||
use codex_features::Feature;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use core_test_support::PathBufExt;
|
||||
use core_test_support::TempDirExt;
|
||||
use core_test_support::create_directory_symlink;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::ops::Deref;
|
||||
use std::ops::DerefMut;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
async fn get_user_instructions(config: &Config) -> Option<String> {
|
||||
#[derive(Clone, Copy)]
|
||||
enum InjectedFailure {
|
||||
Metadata(io::ErrorKind),
|
||||
Read(io::ErrorKind),
|
||||
}
|
||||
|
||||
struct FailingFileSystem {
|
||||
path: AbsolutePathBuf,
|
||||
failure: InjectedFailure,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ExecutorFileSystem for FailingFileSystem {
|
||||
async fn canonicalize(
|
||||
&self,
|
||||
_path: &PathUri,
|
||||
_sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> io::Result<PathUri> {
|
||||
unreachable!("canonicalize should not be called")
|
||||
}
|
||||
|
||||
async fn read_file(
|
||||
&self,
|
||||
path: &PathUri,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> io::Result<Vec<u8>> {
|
||||
if path.to_abs_path()? == self.path
|
||||
&& let InjectedFailure::Read(kind) = self.failure
|
||||
{
|
||||
return Err(io::Error::new(kind, "injected read failure"));
|
||||
}
|
||||
LOCAL_FS.read_file(path, sandbox).await
|
||||
}
|
||||
|
||||
async fn write_file(
|
||||
&self,
|
||||
_path: &PathUri,
|
||||
_contents: Vec<u8>,
|
||||
_sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> io::Result<()> {
|
||||
unreachable!("write_file should not be called")
|
||||
}
|
||||
|
||||
async fn create_directory(
|
||||
&self,
|
||||
_path: &PathUri,
|
||||
_create_directory_options: CreateDirectoryOptions,
|
||||
_sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> io::Result<()> {
|
||||
unreachable!("create_directory should not be called")
|
||||
}
|
||||
|
||||
async fn get_metadata(
|
||||
&self,
|
||||
path: &PathUri,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> io::Result<FileMetadata> {
|
||||
if path.to_abs_path()? == self.path
|
||||
&& let InjectedFailure::Metadata(kind) = self.failure
|
||||
{
|
||||
return Err(io::Error::new(kind, "injected metadata failure"));
|
||||
}
|
||||
LOCAL_FS.get_metadata(path, sandbox).await
|
||||
}
|
||||
|
||||
async fn read_directory(
|
||||
&self,
|
||||
_path: &PathUri,
|
||||
_sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> io::Result<Vec<ReadDirectoryEntry>> {
|
||||
unreachable!("read_directory should not be called")
|
||||
}
|
||||
|
||||
async fn remove(
|
||||
&self,
|
||||
_path: &PathUri,
|
||||
_remove_options: RemoveOptions,
|
||||
_sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> io::Result<()> {
|
||||
unreachable!("remove should not be called")
|
||||
}
|
||||
|
||||
async fn copy(
|
||||
&self,
|
||||
_source_path: &PathUri,
|
||||
_destination_path: &PathUri,
|
||||
_copy_options: CopyOptions,
|
||||
_sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> io::Result<()> {
|
||||
unreachable!("copy should not be called")
|
||||
}
|
||||
}
|
||||
|
||||
struct TestConfig {
|
||||
config: Config,
|
||||
user_instructions: Option<UserInstructions>,
|
||||
}
|
||||
|
||||
impl Deref for TestConfig {
|
||||
type Target = Config;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.config
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for TestConfig {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.config
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_user_instructions(config: &TestConfig) -> Option<String> {
|
||||
let mut warnings = Vec::new();
|
||||
AgentsMdManager::new(config)
|
||||
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
|
||||
load_agents_md(config, &mut warnings)
|
||||
.await
|
||||
.map(|loaded| loaded.text())
|
||||
}
|
||||
|
||||
async fn agents_md_paths(config: &Config) -> std::io::Result<Vec<AbsolutePathBuf>> {
|
||||
AgentsMdManager::new(config)
|
||||
.agents_md_paths(LOCAL_FS.as_ref())
|
||||
.await
|
||||
async fn load_agents_md(config: &TestConfig, warnings: &mut Vec<String>) -> Option<LoadedAgentsMd> {
|
||||
let mut core_config = config.config.clone();
|
||||
let existing_warning_count = core_config.startup_warnings.len();
|
||||
let loaded = load_project_instructions(
|
||||
&mut core_config,
|
||||
config.user_instructions.clone(),
|
||||
Some(LOCAL_FS.as_ref()),
|
||||
)
|
||||
.await;
|
||||
warnings.extend(
|
||||
core_config
|
||||
.startup_warnings
|
||||
.into_iter()
|
||||
.skip(existing_warning_count),
|
||||
);
|
||||
loaded
|
||||
}
|
||||
|
||||
async fn agents_md_paths(config: &TestConfig) -> std::io::Result<Vec<AbsolutePathBuf>> {
|
||||
super::agents_md_paths(&config.config, LOCAL_FS.as_ref()).await
|
||||
}
|
||||
|
||||
fn assert_invalid_utf8_warning(warnings: &[String], source: &str, path: &Path) {
|
||||
@@ -44,7 +187,7 @@ fn assert_invalid_utf8_warning(warnings: &[String], source: &str, path: &Path) {
|
||||
/// optionally specify a custom `instructions` string – when `None` the
|
||||
/// value is cleared to mimic a scenario where no system instructions have
|
||||
/// been configured.
|
||||
async fn make_config(root: &TempDir, limit: usize, instructions: Option<&str>) -> Config {
|
||||
async fn make_config(root: &TempDir, limit: usize, instructions: Option<&str>) -> TestConfig {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
@@ -55,13 +198,14 @@ async fn make_config(root: &TempDir, limit: usize, instructions: Option<&str>) -
|
||||
config.cwd = root.abs();
|
||||
config.project_doc_max_bytes = limit;
|
||||
|
||||
config.user_instructions = instructions.map(|text| {
|
||||
LoadedAgentsMd::new_user(
|
||||
text.to_owned(),
|
||||
config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME),
|
||||
)
|
||||
let user_instructions = instructions.map(|text| UserInstructions {
|
||||
text: text.to_owned(),
|
||||
source: config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME),
|
||||
});
|
||||
config
|
||||
TestConfig {
|
||||
config,
|
||||
user_instructions,
|
||||
}
|
||||
}
|
||||
|
||||
async fn make_config_with_fallback(
|
||||
@@ -69,7 +213,7 @@ async fn make_config_with_fallback(
|
||||
limit: usize,
|
||||
instructions: Option<&str>,
|
||||
fallbacks: &[&str],
|
||||
) -> Config {
|
||||
) -> TestConfig {
|
||||
let mut config = make_config(root, limit, instructions).await;
|
||||
config.project_doc_fallback_filenames = fallbacks
|
||||
.iter()
|
||||
@@ -83,7 +227,7 @@ async fn make_config_with_project_root_markers(
|
||||
limit: usize,
|
||||
instructions: Option<&str>,
|
||||
markers: &[&str],
|
||||
) -> Config {
|
||||
) -> TestConfig {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let cli_overrides = vec![(
|
||||
"project_root_markers".to_string(),
|
||||
@@ -103,13 +247,14 @@ async fn make_config_with_project_root_markers(
|
||||
|
||||
config.cwd = root.abs();
|
||||
config.project_doc_max_bytes = limit;
|
||||
config.user_instructions = instructions.map(|text| {
|
||||
LoadedAgentsMd::new_user(
|
||||
text.to_owned(),
|
||||
config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME),
|
||||
)
|
||||
let user_instructions = instructions.map(|text| UserInstructions {
|
||||
text: text.to_owned(),
|
||||
source: config.codex_home.join(DEFAULT_AGENTS_MD_FILENAME),
|
||||
});
|
||||
config
|
||||
TestConfig {
|
||||
config,
|
||||
user_instructions,
|
||||
}
|
||||
}
|
||||
|
||||
/// AGENTS.md missing – should yield `None`.
|
||||
@@ -153,12 +298,14 @@ fn empty_loaded_instructions_are_empty() {
|
||||
#[test]
|
||||
fn loaded_instructions_with_only_empty_or_whitespace_entries_are_empty() {
|
||||
let empty = LoadedAgentsMd {
|
||||
user_instructions: None,
|
||||
entries: vec![InstructionEntry {
|
||||
contents: String::new(),
|
||||
provenance: InstructionProvenance::Internal,
|
||||
}],
|
||||
};
|
||||
let whitespace = LoadedAgentsMd {
|
||||
user_instructions: None,
|
||||
entries: vec![InstructionEntry {
|
||||
contents: " \n\t".to_string(),
|
||||
provenance: InstructionProvenance::Internal,
|
||||
@@ -186,29 +333,6 @@ async fn doc_smaller_than_limit_is_returned() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn global_doc_invalid_utf8_warns_and_uses_lossy_text() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let codex_home_abs = codex_home.abs();
|
||||
let path = codex_home_abs.join(DEFAULT_AGENTS_MD_FILENAME);
|
||||
fs::write(&path, b"global\xFF doc").unwrap();
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
let loaded = AgentsMdManager::load_global_instructions(
|
||||
LOCAL_FS.as_ref(),
|
||||
Some(&codex_home_abs),
|
||||
&mut warnings,
|
||||
)
|
||||
.await
|
||||
.expect("global doc expected");
|
||||
|
||||
assert_eq!(
|
||||
loaded,
|
||||
LoadedAgentsMd::new_user("global\u{FFFD} doc".to_string(), path.clone())
|
||||
);
|
||||
assert_invalid_utf8_warning(&warnings, "Global", path.as_path());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_doc_invalid_utf8_warns_and_uses_lossy_text() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
@@ -217,8 +341,7 @@ async fn project_doc_invalid_utf8_warns_and_uses_lossy_text() {
|
||||
|
||||
let config = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
|
||||
let mut warnings = Vec::new();
|
||||
let res = AgentsMdManager::new(&config)
|
||||
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
|
||||
let res = load_agents_md(&config, &mut warnings)
|
||||
.await
|
||||
.expect("doc expected")
|
||||
.text();
|
||||
@@ -244,6 +367,92 @@ async fn doc_larger_than_limit_is_truncated() {
|
||||
assert_eq!(res, huge[..LIMIT]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn total_byte_limit_truncates_later_project_docs() {
|
||||
let repo = tempfile::tempdir().expect("tempdir");
|
||||
fs::write(repo.path().join(".git"), "").unwrap();
|
||||
fs::write(repo.path().join("AGENTS.md"), "root").unwrap();
|
||||
let nested = repo.path().join("nested");
|
||||
fs::create_dir(&nested).unwrap();
|
||||
fs::write(nested.join("AGENTS.md"), "abcdef").unwrap();
|
||||
|
||||
let mut config = make_config(&repo, /*limit*/ 7, /*instructions*/ None).await;
|
||||
config.cwd = nested.abs();
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
let loaded = load_agents_md(&config, &mut warnings)
|
||||
.await
|
||||
.expect("project instructions");
|
||||
let expected = LoadedAgentsMd {
|
||||
user_instructions: None,
|
||||
entries: vec![
|
||||
InstructionEntry {
|
||||
contents: "root".to_string(),
|
||||
provenance: InstructionProvenance::Project(repo.path().join("AGENTS.md").abs()),
|
||||
},
|
||||
InstructionEntry {
|
||||
contents: "abc".to_string(),
|
||||
provenance: InstructionProvenance::Project(config.cwd.join("AGENTS.md")),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
assert_eq!(loaded, expected);
|
||||
assert_eq!(loaded.text(), "root\n\nabc");
|
||||
assert_eq!(warnings, Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_agents_md_propagates_metadata_errors() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let mut config = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
|
||||
let marker_path = config.cwd.join(".git");
|
||||
let fs = FailingFileSystem {
|
||||
path: marker_path,
|
||||
failure: InjectedFailure::Metadata(io::ErrorKind::PermissionDenied),
|
||||
};
|
||||
|
||||
let err = read_agents_md(&mut config.config, &fs)
|
||||
.await
|
||||
.expect_err("metadata error");
|
||||
|
||||
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_agents_md_propagates_read_errors() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap();
|
||||
let mut config = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
|
||||
let fs = FailingFileSystem {
|
||||
path: config.cwd.join("AGENTS.md"),
|
||||
failure: InjectedFailure::Read(io::ErrorKind::PermissionDenied),
|
||||
};
|
||||
|
||||
let err = read_agents_md(&mut config.config, &fs)
|
||||
.await
|
||||
.expect_err("read error");
|
||||
|
||||
assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_agents_md_ignores_files_removed_after_discovery() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap();
|
||||
let mut config = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
|
||||
let fs = FailingFileSystem {
|
||||
path: config.cwd.join("AGENTS.md"),
|
||||
failure: InjectedFailure::Read(io::ErrorKind::NotFound),
|
||||
};
|
||||
|
||||
let loaded = read_agents_md(&mut config.config, &fs)
|
||||
.await
|
||||
.expect("removed file is recoverable");
|
||||
|
||||
assert_eq!(loaded, None);
|
||||
}
|
||||
|
||||
/// When `cwd` is nested inside a repo, the search should locate AGENTS.md
|
||||
/// placed at the repository root (identified by `.git`).
|
||||
#[tokio::test]
|
||||
@@ -286,17 +495,6 @@ async fn zero_byte_limit_disables_docs() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zero_byte_limit_disables_discovery() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
fs::write(tmp.path().join("AGENTS.md"), "something").unwrap();
|
||||
|
||||
let discovery = agents_md_paths(&make_config(&tmp, /*limit*/ 0, /*instructions*/ None).await)
|
||||
.await
|
||||
.expect("discover paths");
|
||||
assert_eq!(discovery, Vec::<AbsolutePathBuf>::new());
|
||||
}
|
||||
|
||||
/// When both system instructions and AGENTS.md docs are present the two
|
||||
/// should be concatenated with the separator.
|
||||
#[tokio::test]
|
||||
@@ -315,30 +513,6 @@ async fn merges_existing_instructions_with_agents_md() {
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sourceless_user_instructions_preserve_separator_without_reporting_a_source() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap();
|
||||
|
||||
let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
|
||||
cfg.user_instructions = Some(LoadedAgentsMd::from_text_for_testing(
|
||||
"user instructions".to_string(),
|
||||
));
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
let loaded = AgentsMdManager::new(&cfg)
|
||||
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
|
||||
.await
|
||||
.expect("instructions expected");
|
||||
let project_agents = cfg.cwd.join("AGENTS.md");
|
||||
|
||||
assert_eq!(
|
||||
loaded.text(),
|
||||
format!("user instructions{AGENTS_MD_SEPARATOR}project doc")
|
||||
);
|
||||
assert_eq!(loaded.sources().collect::<Vec<_>>(), vec![&project_agents]);
|
||||
}
|
||||
|
||||
/// If there are existing system instructions but AGENTS.md docs are
|
||||
/// missing we expect the original instructions to be returned unchanged.
|
||||
#[tokio::test]
|
||||
@@ -378,13 +552,13 @@ async fn concatenates_root_and_cwd_docs() {
|
||||
cfg.cwd = nested.abs();
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
let loaded = AgentsMdManager::new(&cfg)
|
||||
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
|
||||
let loaded = load_agents_md(&cfg, &mut warnings)
|
||||
.await
|
||||
.expect("doc expected");
|
||||
let root_agents = repo.path().join("AGENTS.md").abs();
|
||||
let crate_agents = cfg.cwd.join("AGENTS.md");
|
||||
let expected = LoadedAgentsMd {
|
||||
user_instructions: None,
|
||||
entries: vec![
|
||||
InstructionEntry {
|
||||
contents: "root doc".to_string(),
|
||||
@@ -435,6 +609,51 @@ async fn project_root_markers_are_honored_for_agents_discovery() {
|
||||
assert_eq!(res, "parent doc\n\nchild doc");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_layers_do_not_override_project_root_markers() {
|
||||
let root = tempfile::tempdir().expect("tempdir");
|
||||
fs::write(root.path().join(".git"), "").unwrap();
|
||||
fs::write(root.path().join("AGENTS.md"), "root doc").unwrap();
|
||||
let nested = root.path().join("nested");
|
||||
fs::create_dir(&nested).unwrap();
|
||||
fs::write(nested.join("AGENTS.md"), "nested doc").unwrap();
|
||||
|
||||
let mut config = make_config(&root, /*limit*/ 4096, /*instructions*/ None).await;
|
||||
config.cwd = nested.abs();
|
||||
let project_layer = |dot_codex_folder: AbsolutePathBuf, marker: &str| {
|
||||
ConfigLayerEntry::new(
|
||||
ConfigLayerSource::Project { dot_codex_folder },
|
||||
TomlValue::Table(
|
||||
[(
|
||||
"project_root_markers".to_string(),
|
||||
TomlValue::Array(vec![TomlValue::String(marker.to_string())]),
|
||||
)]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
)
|
||||
};
|
||||
config.config_layer_stack = ConfigLayerStack::new(
|
||||
vec![
|
||||
project_layer(root.path().join(".codex").abs(), ".ignored-root-marker"),
|
||||
project_layer(config.cwd.join(".codex"), ".ignored-nested-marker"),
|
||||
],
|
||||
ConfigRequirements::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("valid project layer ordering");
|
||||
|
||||
let discovery = agents_md_paths(&config).await.expect("discover paths");
|
||||
|
||||
assert_eq!(
|
||||
discovery,
|
||||
vec![
|
||||
root.path().join("AGENTS.md").abs(),
|
||||
config.cwd.join("AGENTS.md"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agents_md_paths_preserve_symlinked_cwd() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
@@ -462,22 +681,19 @@ async fn child_agents_message_after_global_instructions_uses_plain_separator() {
|
||||
cfg.features.enable(Feature::ChildAgentsMd).unwrap();
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
let loaded = AgentsMdManager::new(&cfg)
|
||||
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
|
||||
let loaded = load_agents_md(&cfg, &mut warnings)
|
||||
.await
|
||||
.expect("instructions expected");
|
||||
let global_agents = cfg.codex_home.join(DEFAULT_AGENTS_MD_FILENAME);
|
||||
let expected = LoadedAgentsMd {
|
||||
entries: vec![
|
||||
InstructionEntry {
|
||||
contents: "global doc".to_string(),
|
||||
provenance: InstructionProvenance::User(global_agents),
|
||||
},
|
||||
InstructionEntry {
|
||||
contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(),
|
||||
provenance: InstructionProvenance::Internal,
|
||||
},
|
||||
],
|
||||
user_instructions: Some(UserInstructions {
|
||||
text: "global doc".to_string(),
|
||||
source: global_agents,
|
||||
}),
|
||||
entries: vec![InstructionEntry {
|
||||
contents: HIERARCHICAL_AGENTS_MESSAGE.to_string(),
|
||||
provenance: InstructionProvenance::Internal,
|
||||
}],
|
||||
};
|
||||
|
||||
assert_eq!(loaded, expected);
|
||||
@@ -498,25 +714,23 @@ async fn instruction_sources_include_global_before_agents_md_docs() {
|
||||
fs::write(&global_agents, "global doc").unwrap();
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
let loaded = AgentsMdManager::new(&cfg)
|
||||
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
|
||||
let loaded = load_agents_md(&cfg, &mut warnings)
|
||||
.await
|
||||
.expect("instructions expected");
|
||||
let project_agents = cfg.cwd.join("AGENTS.md");
|
||||
|
||||
let expected = LoadedAgentsMd {
|
||||
entries: vec![
|
||||
InstructionEntry {
|
||||
contents: "global doc".to_string(),
|
||||
provenance: InstructionProvenance::User(global_agents.clone()),
|
||||
},
|
||||
InstructionEntry {
|
||||
contents: "project doc".to_string(),
|
||||
provenance: InstructionProvenance::Project(project_agents.clone()),
|
||||
},
|
||||
],
|
||||
user_instructions: Some(UserInstructions {
|
||||
text: "global doc".to_string(),
|
||||
source: global_agents.clone(),
|
||||
}),
|
||||
entries: vec![InstructionEntry {
|
||||
contents: "project doc".to_string(),
|
||||
provenance: InstructionProvenance::Project(project_agents.clone()),
|
||||
}],
|
||||
};
|
||||
assert_eq!(loaded, expected);
|
||||
assert_eq!(loaded.user_instructions(), cfg.user_instructions.as_ref());
|
||||
assert_eq!(
|
||||
loaded.sources().collect::<Vec<_>>(),
|
||||
vec![&global_agents, &project_agents]
|
||||
@@ -539,18 +753,17 @@ async fn child_agents_message_after_project_docs_is_not_an_instruction_source()
|
||||
fs::write(&global_agents, "global doc").unwrap();
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
let loaded = AgentsMdManager::new(&cfg)
|
||||
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
|
||||
let loaded = load_agents_md(&cfg, &mut warnings)
|
||||
.await
|
||||
.expect("instructions expected");
|
||||
let project_agents = cfg.cwd.join("AGENTS.md");
|
||||
|
||||
let expected = LoadedAgentsMd {
|
||||
user_instructions: Some(UserInstructions {
|
||||
text: "global doc".to_string(),
|
||||
source: global_agents.clone(),
|
||||
}),
|
||||
entries: vec![
|
||||
InstructionEntry {
|
||||
contents: "global doc".to_string(),
|
||||
provenance: InstructionProvenance::User(global_agents.clone()),
|
||||
},
|
||||
InstructionEntry {
|
||||
contents: "project doc".to_string(),
|
||||
provenance: InstructionProvenance::Project(project_agents.clone()),
|
||||
|
||||
@@ -746,7 +746,7 @@ impl ModelClient {
|
||||
window_id: &str,
|
||||
) -> Result<ResponsesApiRequest> {
|
||||
let instructions = &prompt.base_instructions.text;
|
||||
let input = prompt.get_formatted_input();
|
||||
let input = prompt.get_formatted_input_for_request(model_info.use_responses_lite);
|
||||
let tools = create_tools_json_for_responses_api(&prompt.tools)?;
|
||||
let reasoning = Self::build_reasoning(model_info, effort, summary);
|
||||
let include = if reasoning.is_some() {
|
||||
@@ -1985,7 +1985,7 @@ impl AuthRequestTelemetryContext {
|
||||
let auth_telemetry = auth_header_telemetry(api_auth);
|
||||
Self {
|
||||
auth_mode: auth_mode.map(|mode| match mode {
|
||||
AuthMode::ApiKey => "ApiKey",
|
||||
AuthMode::ApiKey | AuthMode::BedrockApiKey => "ApiKey",
|
||||
AuthMode::Chatgpt
|
||||
| AuthMode::ChatgptAuthTokens
|
||||
| AuthMode::AgentIdentity
|
||||
|
||||
@@ -2,6 +2,8 @@ pub use codex_api::ResponseEvent;
|
||||
use codex_config::types::Personality;
|
||||
use codex_protocol::error::Result;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_tools::ToolSpec;
|
||||
@@ -71,6 +73,56 @@ impl Prompt {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn get_formatted_input_for_request(
|
||||
&self,
|
||||
use_responses_lite: bool,
|
||||
) -> Vec<ResponseItem> {
|
||||
let mut input = self.get_formatted_input();
|
||||
if use_responses_lite {
|
||||
strip_image_details(&mut input);
|
||||
}
|
||||
input
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_image_details(items: &mut [ResponseItem]) {
|
||||
for item in items {
|
||||
match item {
|
||||
ResponseItem::Message { content, .. } => {
|
||||
for content_item in content {
|
||||
if let ContentItem::InputImage { detail, .. } = content_item {
|
||||
*detail = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
ResponseItem::FunctionCallOutput { output, .. }
|
||||
| ResponseItem::CustomToolCallOutput { output, .. } => {
|
||||
if let Some(content) = output.content_items_mut() {
|
||||
for content_item in content {
|
||||
if let FunctionCallOutputContentItem::InputImage { detail, .. } =
|
||||
content_item
|
||||
{
|
||||
*detail = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ResponseItem::Reasoning { .. }
|
||||
| ResponseItem::AgentMessage { .. }
|
||||
| ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::FunctionCall { .. }
|
||||
| ResponseItem::ToolSearchCall { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::ToolSearchOutput { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::CompactionTrigger
|
||||
| ResponseItem::ContextCompaction { .. }
|
||||
| ResponseItem::Other => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ResponseStream {
|
||||
|
||||
@@ -3,10 +3,95 @@ use codex_api::ResponsesApiRequest;
|
||||
use codex_api::TextControls;
|
||||
use codex_api::create_text_param_for_request;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ImageDetail;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn prompt_with_image_outputs() -> Prompt {
|
||||
Prompt {
|
||||
input: vec![
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputImage {
|
||||
image_url: "https://example.com/image.png".to_string(),
|
||||
detail: Some(ImageDetail::Original),
|
||||
}],
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::FunctionCallOutput {
|
||||
call_id: "function-call".to_string(),
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,function".to_string(),
|
||||
detail: Some(ImageDetail::High),
|
||||
},
|
||||
]),
|
||||
},
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: "custom-call".to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,custom".to_string(),
|
||||
detail: Some(ImageDetail::Auto),
|
||||
},
|
||||
]),
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_lite_request_copies_strip_image_details() {
|
||||
let prompt = prompt_with_image_outputs();
|
||||
let original = prompt.input.clone();
|
||||
|
||||
let stripped = prompt.get_formatted_input_for_request(/*use_responses_lite*/ true);
|
||||
|
||||
assert_eq!(
|
||||
stripped,
|
||||
vec![
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputImage {
|
||||
image_url: "https://example.com/image.png".to_string(),
|
||||
detail: None,
|
||||
}],
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::FunctionCallOutput {
|
||||
call_id: "function-call".to_string(),
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,function".to_string(),
|
||||
detail: None,
|
||||
},
|
||||
]),
|
||||
},
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: "custom-call".to_string(),
|
||||
name: None,
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,custom".to_string(),
|
||||
detail: None,
|
||||
},
|
||||
]),
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(prompt.input, original);
|
||||
assert_eq!(
|
||||
prompt.get_formatted_input_for_request(/*use_responses_lite*/ false),
|
||||
original
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_text_verbosity_when_set() {
|
||||
let input: Vec<ResponseItem> = vec![];
|
||||
|
||||
@@ -5,6 +5,7 @@ use async_channel::Receiver;
|
||||
use async_channel::Sender;
|
||||
use codex_analytics::GuardianApprovalRequestSource;
|
||||
use codex_async_utils::OrCancelExt;
|
||||
use codex_extension_api::LoadedUserInstructions;
|
||||
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use codex_protocol::protocol::Event;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -79,8 +80,13 @@ pub(crate) async fn run_codex_thread_interactive(
|
||||
let (tx_ops, rx_ops) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let conversation_history = initial_history.unwrap_or(InitialHistory::New);
|
||||
let forked_from_thread_id = conversation_history.forked_from_id();
|
||||
let user_instructions = LoadedUserInstructions {
|
||||
instructions: parent_session.user_instructions().await,
|
||||
warnings: Vec::new(),
|
||||
};
|
||||
let CodexSpawnOk { codex, .. } = Box::pin(Codex::spawn(CodexSpawnArgs {
|
||||
config,
|
||||
user_instructions,
|
||||
installation_id: parent_session.installation_id.clone(),
|
||||
auth_manager,
|
||||
models_manager,
|
||||
|
||||
@@ -143,17 +143,17 @@ async fn run_compact_task_inner(
|
||||
let pre_compact_outcome = run_pre_compact_hooks(&sess, &turn_context, trigger).await;
|
||||
match pre_compact_outcome {
|
||||
PreCompactHookOutcome::Continue => {}
|
||||
PreCompactHookOutcome::Stopped { reason } => {
|
||||
let error = reason.unwrap_or_else(|| "PreCompact hook stopped execution".to_string());
|
||||
PreCompactHookOutcome::Stopped => {
|
||||
let error = CodexErr::TurnAborted;
|
||||
attempt
|
||||
.track(
|
||||
sess.as_ref(),
|
||||
CompactionStatus::Interrupted,
|
||||
Some(error),
|
||||
Some(&error),
|
||||
CompactionAnalyticsDetails::default(),
|
||||
)
|
||||
.await;
|
||||
return Err(CodexErr::TurnAborted);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
let result = run_compact_task_inner_impl(
|
||||
@@ -165,7 +165,7 @@ async fn run_compact_task_inner(
|
||||
)
|
||||
.await;
|
||||
let status = compaction_status_from_result(&result);
|
||||
let error = result.as_ref().err().map(ToString::to_string);
|
||||
let codex_error = result.as_ref().err();
|
||||
if result.is_ok() {
|
||||
let post_compact_outcome = run_post_compact_hooks(&sess, &turn_context, trigger).await;
|
||||
if let PostCompactHookOutcome::Stopped = post_compact_outcome {
|
||||
@@ -173,7 +173,7 @@ async fn run_compact_task_inner(
|
||||
.track(
|
||||
sess.as_ref(),
|
||||
status,
|
||||
error,
|
||||
codex_error,
|
||||
CompactionAnalyticsDetails::default(),
|
||||
)
|
||||
.await;
|
||||
@@ -184,7 +184,7 @@ async fn run_compact_task_inner(
|
||||
.track(
|
||||
sess.as_ref(),
|
||||
status,
|
||||
error,
|
||||
codex_error,
|
||||
CompactionAnalyticsDetails::default(),
|
||||
)
|
||||
.await;
|
||||
@@ -294,6 +294,7 @@ async fn run_compact_task_inner_impl(
|
||||
let user_messages = collect_user_messages(history_items);
|
||||
|
||||
let mut new_history = build_compacted_history(Vec::new(), &user_messages, &summary_text);
|
||||
let window_id = sess.advance_auto_compact_window_id().await;
|
||||
|
||||
if matches!(
|
||||
initial_context_injection,
|
||||
@@ -310,7 +311,7 @@ async fn run_compact_task_inner_impl(
|
||||
let compacted_item = CompactedItem {
|
||||
message: summary_text.clone(),
|
||||
replacement_history: Some(new_history.clone()),
|
||||
window_id: None,
|
||||
window_id: Some(window_id),
|
||||
};
|
||||
sess.replace_compacted_history(new_history, reference_context_item, compacted_item)
|
||||
.await;
|
||||
@@ -342,6 +343,7 @@ pub(crate) struct CompactionAnalyticsDetails {
|
||||
pub(crate) active_context_tokens_before: Option<i64>,
|
||||
pub(crate) retained_image_count: Option<usize>,
|
||||
pub(crate) compaction_summary_tokens: Option<i64>,
|
||||
pub(crate) cached_input_tokens: Option<i64>,
|
||||
}
|
||||
|
||||
impl CompactionAnalyticsAttempt {
|
||||
@@ -371,13 +373,14 @@ impl CompactionAnalyticsAttempt {
|
||||
self,
|
||||
sess: &Session,
|
||||
status: CompactionStatus,
|
||||
error: Option<String>,
|
||||
codex_error: Option<&CodexErr>,
|
||||
details: CompactionAnalyticsDetails,
|
||||
) {
|
||||
let CompactionAnalyticsDetails {
|
||||
active_context_tokens_before,
|
||||
retained_image_count,
|
||||
compaction_summary_tokens,
|
||||
cached_input_tokens,
|
||||
} = details;
|
||||
let active_context_tokens_before =
|
||||
active_context_tokens_before.unwrap_or(self.active_context_tokens_before);
|
||||
@@ -393,11 +396,14 @@ impl CompactionAnalyticsAttempt {
|
||||
phase: self.phase,
|
||||
strategy: CompactionStrategy::Memento,
|
||||
status,
|
||||
error,
|
||||
codex_error_kind: codex_error.map(Into::into),
|
||||
codex_error_http_status_code: codex_error
|
||||
.and_then(CodexErr::http_status_code_value),
|
||||
active_context_tokens_before,
|
||||
active_context_tokens_after,
|
||||
retained_image_count,
|
||||
compaction_summary_tokens,
|
||||
cached_input_tokens,
|
||||
started_at: self.started_at,
|
||||
completed_at: now_unix_seconds(),
|
||||
duration_ms: Some(
|
||||
|
||||
@@ -113,17 +113,17 @@ async fn run_remote_compact_task_inner(
|
||||
let pre_compact_outcome = run_pre_compact_hooks(sess, turn_context, trigger).await;
|
||||
match pre_compact_outcome {
|
||||
PreCompactHookOutcome::Continue => {}
|
||||
PreCompactHookOutcome::Stopped { reason } => {
|
||||
let error = reason.unwrap_or_else(|| "PreCompact hook stopped execution".to_string());
|
||||
PreCompactHookOutcome::Stopped => {
|
||||
let error = CodexErr::TurnAborted;
|
||||
attempt
|
||||
.track(
|
||||
sess.as_ref(),
|
||||
codex_analytics::CompactionStatus::Interrupted,
|
||||
Some(error),
|
||||
Some(&error),
|
||||
analytics_details,
|
||||
)
|
||||
.await;
|
||||
return Err(CodexErr::TurnAborted);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
let result = run_remote_compact_task_inner_impl(
|
||||
@@ -135,18 +135,18 @@ async fn run_remote_compact_task_inner(
|
||||
)
|
||||
.await;
|
||||
let status = compaction_status_from_result(&result);
|
||||
let error = result.as_ref().err().map(ToString::to_string);
|
||||
let codex_error = result.as_ref().err();
|
||||
if result.is_ok() {
|
||||
let post_compact_outcome = run_post_compact_hooks(sess, turn_context, trigger).await;
|
||||
if let PostCompactHookOutcome::Stopped = post_compact_outcome {
|
||||
attempt
|
||||
.track(sess.as_ref(), status, error, analytics_details)
|
||||
.track(sess.as_ref(), status, codex_error, analytics_details)
|
||||
.await;
|
||||
return Err(CodexErr::TurnAborted);
|
||||
}
|
||||
}
|
||||
attempt
|
||||
.track(sess.as_ref(), status, error.clone(), analytics_details)
|
||||
.track(sess.as_ref(), status, codex_error, analytics_details)
|
||||
.await;
|
||||
if let Err(err) = result {
|
||||
sess.track_turn_codex_error(turn_context, &err);
|
||||
@@ -249,6 +249,7 @@ async fn run_remote_compact_task_inner_impl(
|
||||
turn_metadata_header.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
let new_window_id = sess.advance_auto_compact_window_id().await;
|
||||
new_history = process_compacted_history(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
@@ -264,7 +265,7 @@ async fn run_remote_compact_task_inner_impl(
|
||||
let compacted_item = CompactedItem {
|
||||
message: String::new(),
|
||||
replacement_history: Some(new_history.clone()),
|
||||
window_id: None,
|
||||
window_id: Some(new_window_id),
|
||||
};
|
||||
// Install is the semantic boundary where the compact endpoint's output becomes live
|
||||
// thread history. Keep it distinct from the later inference request so the reducer can
|
||||
|
||||
@@ -127,17 +127,17 @@ async fn run_remote_compact_task_inner(
|
||||
let pre_compact_outcome = run_pre_compact_hooks(sess, turn_context, trigger).await;
|
||||
match pre_compact_outcome {
|
||||
PreCompactHookOutcome::Continue => {}
|
||||
PreCompactHookOutcome::Stopped { reason } => {
|
||||
let error = reason.unwrap_or_else(|| "PreCompact hook stopped execution".to_string());
|
||||
PreCompactHookOutcome::Stopped => {
|
||||
let error = CodexErr::TurnAborted;
|
||||
attempt
|
||||
.track(
|
||||
sess.as_ref(),
|
||||
codex_analytics::CompactionStatus::Interrupted,
|
||||
Some(error),
|
||||
Some(&error),
|
||||
analytics_details,
|
||||
)
|
||||
.await;
|
||||
return Err(CodexErr::TurnAborted);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
let result = run_remote_compact_task_inner_impl(
|
||||
@@ -150,18 +150,18 @@ async fn run_remote_compact_task_inner(
|
||||
)
|
||||
.await;
|
||||
let status = compaction_status_from_result(&result);
|
||||
let error = result.as_ref().err().map(ToString::to_string);
|
||||
let codex_error = result.as_ref().err();
|
||||
if result.is_ok() {
|
||||
let post_compact_outcome = run_post_compact_hooks(sess, turn_context, trigger).await;
|
||||
if let PostCompactHookOutcome::Stopped = post_compact_outcome {
|
||||
attempt
|
||||
.track(sess.as_ref(), status, error, analytics_details)
|
||||
.track(sess.as_ref(), status, codex_error, analytics_details)
|
||||
.await;
|
||||
return Err(CodexErr::TurnAborted);
|
||||
}
|
||||
}
|
||||
attempt
|
||||
.track(sess.as_ref(), status, error.clone(), analytics_details)
|
||||
.track(sess.as_ref(), status, codex_error, analytics_details)
|
||||
.await;
|
||||
if let Err(err) = result {
|
||||
sess.track_turn_codex_error(turn_context, &err);
|
||||
@@ -281,10 +281,12 @@ async fn run_remote_compact_task_inner_impl(
|
||||
if let Some(token_usage) = token_usage {
|
||||
analytics_details.active_context_tokens_before = Some(token_usage.input_tokens);
|
||||
analytics_details.compaction_summary_tokens = Some(token_usage.output_tokens);
|
||||
analytics_details.cached_input_tokens = Some(token_usage.cached_input_tokens);
|
||||
}
|
||||
let (compacted_history, retained_images) =
|
||||
build_v2_compacted_history(&prompt_input, compaction_output);
|
||||
analytics_details.retained_image_count = Some(retained_images);
|
||||
let new_window_id = sess.advance_auto_compact_window_id().await;
|
||||
let new_history = process_compacted_history(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
@@ -300,7 +302,7 @@ async fn run_remote_compact_task_inner_impl(
|
||||
let compacted_item = CompactedItem {
|
||||
message: String::new(),
|
||||
replacement_history: Some(new_history.clone()),
|
||||
window_id: None,
|
||||
window_id: Some(new_window_id),
|
||||
};
|
||||
compaction_trace.record_installed(&CompactionCheckpointTracePayload {
|
||||
input_history: &trace_input_history,
|
||||
|
||||
@@ -483,6 +483,7 @@ async fn process_compacted_history_reinjects_model_switch_message() {
|
||||
}];
|
||||
let previous_turn_settings = PreviousTurnSettings {
|
||||
model: "previous-regular-model".to_string(),
|
||||
comp_hash: None,
|
||||
realtime_active: None,
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use codex_config::config_toml::ConfigToml;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use serde::Deserialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
@@ -319,7 +320,8 @@ async fn read_resolved_agent_role_file(
|
||||
path: &AbsolutePathBuf,
|
||||
role_name_hint: Option<&str>,
|
||||
) -> std::io::Result<ResolvedAgentRoleFile> {
|
||||
let contents = fs.read_file_text(path, /*sandbox*/ None).await?;
|
||||
let path_uri = PathUri::from_abs_path(path)?;
|
||||
let contents = fs.read_file_text(&path_uri, /*sandbox*/ None).await?;
|
||||
let config_base_dir = path.parent().unwrap_or_else(|| path.clone());
|
||||
parse_agent_role_file_contents(
|
||||
&contents,
|
||||
@@ -391,8 +393,9 @@ async fn validate_agent_role_config_file(
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let config_file_uri = PathUri::from_abs_path(config_file)?;
|
||||
let metadata = fs
|
||||
.get_metadata(config_file, /*sandbox*/ None)
|
||||
.get_metadata(&config_file_uri, /*sandbox*/ None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
std::io::Error::new(
|
||||
@@ -522,7 +525,8 @@ async fn collect_agent_role_files(
|
||||
let mut files = Vec::new();
|
||||
let mut dirs = vec![dir.clone()];
|
||||
while let Some(dir) = dirs.pop() {
|
||||
let entries = match fs.read_directory(&dir, /*sandbox*/ None).await {
|
||||
let dir_uri = PathUri::from_abs_path(&dir)?;
|
||||
let entries = match fs.read_directory(&dir_uri, /*sandbox*/ None).await {
|
||||
Ok(entries) => entries,
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => continue,
|
||||
Err(err) => return Err(err),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use crate::agents_md::DEFAULT_AGENTS_MD_FILENAME;
|
||||
use crate::agents_md::LOCAL_AGENTS_MD_FILENAME;
|
||||
use crate::config::edit::ConfigEdit;
|
||||
use crate::config::edit::ConfigEditsBuilder;
|
||||
use crate::config::edit::apply_blocking;
|
||||
@@ -205,61 +203,6 @@ async fn load_config_normalizes_relative_cwd_override() -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_config_loads_global_agents_instructions() -> std::io::Result<()> {
|
||||
let codex_home = tempdir()?;
|
||||
let global_agents_path = codex_home.abs().join(DEFAULT_AGENTS_MD_FILENAME);
|
||||
std::fs::write(&global_agents_path, "\n global instructions \n")?;
|
||||
|
||||
let mut config = Config::load_from_base_config_with_overrides(
|
||||
ConfigToml::default(),
|
||||
ConfigOverrides::default(),
|
||||
codex_home.abs(),
|
||||
)
|
||||
.await?;
|
||||
let _ = config.features.enable(Feature::MemoryTool);
|
||||
|
||||
let user_instructions = config
|
||||
.user_instructions
|
||||
.as_ref()
|
||||
.expect("global instructions expected");
|
||||
assert_eq!(user_instructions.text(), "global instructions");
|
||||
assert_eq!(
|
||||
user_instructions.sources().collect::<Vec<_>>(),
|
||||
vec![&global_agents_path]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn load_config_prefers_global_agents_override_instructions() -> std::io::Result<()> {
|
||||
let codex_home = tempdir()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(DEFAULT_AGENTS_MD_FILENAME),
|
||||
"global instructions",
|
||||
)?;
|
||||
let global_agents_override_path = codex_home.abs().join(LOCAL_AGENTS_MD_FILENAME);
|
||||
std::fs::write(&global_agents_override_path, "local override instructions")?;
|
||||
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
ConfigToml::default(),
|
||||
ConfigOverrides::default(),
|
||||
codex_home.abs(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let user_instructions = config
|
||||
.user_instructions
|
||||
.as_ref()
|
||||
.expect("global override instructions expected");
|
||||
assert_eq!(user_instructions.text(), "local override instructions");
|
||||
assert_eq!(
|
||||
user_instructions.sources().collect::<Vec<_>>(),
|
||||
vec![&global_agents_override_path]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_toml_parsing() {
|
||||
let history_with_persistence = r#"
|
||||
@@ -4404,13 +4347,14 @@ async fn rebuild_preserving_session_layers_refreshes_plugin_derived_mcp_config()
|
||||
.await?;
|
||||
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
let mcp_config = config.to_mcp_config(&plugins_manager).await;
|
||||
let configured_servers = mcp_config.mcp_server_catalog.configured_servers();
|
||||
|
||||
assert_eq!(
|
||||
mcp_config.configured_mcp_servers.get("sample"),
|
||||
configured_servers.get("sample"),
|
||||
Some(&http_mcp("https://sample.example/mcp"))
|
||||
);
|
||||
assert_eq!(
|
||||
mcp_config.plugin_ids_by_mcp_server_name,
|
||||
mcp_config.mcp_server_catalog.plugin_ids_by_server_name(),
|
||||
HashMap::from([("sample".to_string(), "sample@test".to_string())])
|
||||
);
|
||||
|
||||
@@ -4460,12 +4404,18 @@ enabled = true
|
||||
.await?;
|
||||
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
let mcp_config = config.to_mcp_config(&plugins_manager).await;
|
||||
let configured_servers = mcp_config.mcp_server_catalog.configured_servers();
|
||||
|
||||
assert_eq!(
|
||||
mcp_config.configured_mcp_servers.get("sample"),
|
||||
configured_servers.get("sample"),
|
||||
Some(&http_mcp("https://user.example/mcp"))
|
||||
);
|
||||
assert!(mcp_config.plugin_ids_by_mcp_server_name.is_empty());
|
||||
assert!(
|
||||
mcp_config
|
||||
.mcp_server_catalog
|
||||
.plugin_ids_by_server_name()
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -4522,17 +4472,16 @@ url = "https://sample.example/mcp"
|
||||
.await?;
|
||||
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
let mcp_config = config.to_mcp_config(&plugins_manager).await;
|
||||
let configured_servers = mcp_config.mcp_server_catalog.configured_servers();
|
||||
|
||||
assert_eq!(
|
||||
mcp_config
|
||||
.configured_mcp_servers
|
||||
configured_servers
|
||||
.get("sample")
|
||||
.map(|server| (server.enabled, server.disabled_reason.clone())),
|
||||
Some((true, None))
|
||||
);
|
||||
assert_eq!(
|
||||
mcp_config
|
||||
.configured_mcp_servers
|
||||
configured_servers
|
||||
.get("unlisted")
|
||||
.map(|server| (server.enabled, server.disabled_reason.clone())),
|
||||
Some((
|
||||
@@ -4595,10 +4544,10 @@ enabled = true
|
||||
.await?;
|
||||
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
let mcp_config = config.to_mcp_config(&plugins_manager).await;
|
||||
let configured_servers = mcp_config.mcp_server_catalog.configured_servers();
|
||||
|
||||
assert_eq!(
|
||||
mcp_config
|
||||
.configured_mcp_servers
|
||||
configured_servers
|
||||
.get("sample")
|
||||
.map(|server| (server.enabled, server.disabled_reason.clone())),
|
||||
Some((
|
||||
@@ -9831,10 +9780,7 @@ enabled = true
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 4);
|
||||
assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 10_000);
|
||||
assert_eq!(config.multi_agent_v2.max_wait_timeout_ms, 3_600_000);
|
||||
assert_eq!(config.multi_agent_v2.default_wait_timeout_ms, 30_000);
|
||||
assert_eq!(config.multi_agent_v2, MultiAgentV2Config::default());
|
||||
assert_eq!(
|
||||
(
|
||||
config.agent_max_threads,
|
||||
@@ -9842,36 +9788,32 @@ enabled = true
|
||||
),
|
||||
(None, Some(3))
|
||||
);
|
||||
assert_eq!(
|
||||
config.multi_agent_v2.root_agent_usage_hint_text.as_deref(),
|
||||
Some(DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT)
|
||||
);
|
||||
assert!(
|
||||
!config
|
||||
.multi_agent_v2
|
||||
.root_agent_usage_hint_text
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("maximum concurrency"),
|
||||
);
|
||||
assert_eq!(
|
||||
config.multi_agent_v2.subagent_usage_hint_text.as_deref(),
|
||||
Some(DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT)
|
||||
);
|
||||
assert!(
|
||||
!config
|
||||
.multi_agent_v2
|
||||
.subagent_usage_hint_text
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.contains("maximum concurrency"),
|
||||
);
|
||||
assert!(config.multi_agent_v2.hide_spawn_agent_metadata);
|
||||
assert!(config.multi_agent_v2.non_code_mode_only);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_agent_v2_default_usage_hints_use_configured_thread_cap() {
|
||||
let config_toml = toml::from_str(
|
||||
r#"[features.multi_agent_v2]
|
||||
enabled = true
|
||||
max_concurrent_threads_per_session = 17
|
||||
"#,
|
||||
)
|
||||
.expect("multi-agent v2 config should parse");
|
||||
|
||||
let config = resolve_multi_agent_v2_config(&config_toml);
|
||||
let concurrency_guidance = "There are 17 available concurrency slots, meaning that up to 17 agents can be active at once, including you.";
|
||||
assert!(
|
||||
[
|
||||
config.root_agent_usage_hint_text,
|
||||
config.subagent_usage_hint_text,
|
||||
]
|
||||
.into_iter()
|
||||
.all(|hint| hint.is_some_and(|hint| hint.ends_with(concurrency_guidance)))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_empty_usage_hint_overrides_clear_default_hints() -> std::io::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use crate::agents_md::AgentsMdManager;
|
||||
pub use crate::agents_md::LoadedAgentsMd;
|
||||
use crate::config::edit::ConfigEdit;
|
||||
use crate::config::edit::ConfigEditsBuilder;
|
||||
use crate::path_utils::normalize_for_native_workdir;
|
||||
@@ -71,6 +69,8 @@ use codex_git_utils::resolve_root_git_project_for_trust;
|
||||
use codex_install_context::InstallContext;
|
||||
use codex_login::AuthManagerConfig;
|
||||
use codex_mcp::McpConfig;
|
||||
use codex_mcp::McpServerRegistration;
|
||||
use codex_mcp::ResolvedMcpCatalog;
|
||||
use codex_memories_read::memory_root;
|
||||
use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
@@ -105,6 +105,7 @@ use codex_protocol::protocol::SandboxPolicy;
|
||||
pub use codex_thread_store::ExtraConfig;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use rmcp::model::ElicitationCapability;
|
||||
use rmcp::model::FormElicitationCapability;
|
||||
use rmcp::model::UrlElicitationCapability;
|
||||
@@ -113,7 +114,6 @@ use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -233,6 +233,13 @@ Payload:
|
||||
```
|
||||
You may also see them addressed as to=/root/..., which indicates your identity is /root/...
|
||||
"#;
|
||||
|
||||
fn default_multi_agent_v2_usage_hint_text(usage_hint_text: &str, max_concurrency: usize) -> String {
|
||||
format!(
|
||||
"{usage_hint_text}\nThere are {max_concurrency} available concurrency slots, meaning that up to {max_concurrency} agents can be active at once, including you."
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) const HARD_MIN_MULTI_AGENT_V2_TIMEOUT_MS: i64 = 0;
|
||||
pub(crate) const HARD_MAX_MULTI_AGENT_V2_TIMEOUT_MS: i64 =
|
||||
DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS;
|
||||
@@ -646,9 +653,6 @@ pub struct Config {
|
||||
/// Defaults to `false`.
|
||||
pub show_raw_agent_reasoning: bool,
|
||||
|
||||
/// User-provided instructions from AGENTS.md.
|
||||
pub user_instructions: Option<LoadedAgentsMd>,
|
||||
|
||||
/// Base instructions override.
|
||||
pub base_instructions: Option<String>,
|
||||
|
||||
@@ -1055,22 +1059,23 @@ pub struct MultiAgentV2Config {
|
||||
pub non_code_mode_only: bool,
|
||||
}
|
||||
|
||||
impl Default for MultiAgentV2Config {
|
||||
fn default() -> Self {
|
||||
impl MultiAgentV2Config {
|
||||
fn defaults_for_max_concurrency(max_concurrent_threads_per_session: usize) -> Self {
|
||||
Self {
|
||||
max_concurrent_threads_per_session:
|
||||
DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION,
|
||||
max_concurrent_threads_per_session,
|
||||
min_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS,
|
||||
max_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS,
|
||||
default_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS,
|
||||
usage_hint_enabled: true,
|
||||
usage_hint_text: None,
|
||||
root_agent_usage_hint_text: Some(
|
||||
DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT.to_string(),
|
||||
),
|
||||
subagent_usage_hint_text: Some(
|
||||
DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT.to_string(),
|
||||
),
|
||||
root_agent_usage_hint_text: Some(default_multi_agent_v2_usage_hint_text(
|
||||
DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT,
|
||||
max_concurrent_threads_per_session,
|
||||
)),
|
||||
subagent_usage_hint_text: Some(default_multi_agent_v2_usage_hint_text(
|
||||
DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT,
|
||||
max_concurrent_threads_per_session,
|
||||
)),
|
||||
tool_namespace: None,
|
||||
hide_spawn_agent_metadata: true,
|
||||
non_code_mode_only: true,
|
||||
@@ -1078,6 +1083,14 @@ impl Default for MultiAgentV2Config {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MultiAgentV2Config {
|
||||
fn default() -> Self {
|
||||
Self::defaults_for_max_concurrency(
|
||||
DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum TerminalResizeReflowMaxRows {
|
||||
/// Use the runtime terminal detector to choose a scrollback-sized cap.
|
||||
@@ -1379,12 +1392,18 @@ impl Config {
|
||||
) -> McpConfig {
|
||||
let plugins_input = self.plugins_config_input();
|
||||
let loaded_plugins = plugins_manager.plugins_for_config(&plugins_input).await;
|
||||
let mut configured_mcp_servers = self.mcp_servers.get().clone();
|
||||
let mut plugin_ids_by_mcp_server_name = HashMap::new();
|
||||
for plugin in loaded_plugins
|
||||
let mut catalog = ResolvedMcpCatalog::builder();
|
||||
let empty_mcp_allowlist = self
|
||||
.config_layer_stack
|
||||
.requirements()
|
||||
.mcp_servers
|
||||
.as_ref()
|
||||
.filter(|requirements| requirements.value.is_empty());
|
||||
for (plugin_order, plugin) in loaded_plugins
|
||||
.plugins()
|
||||
.iter()
|
||||
.filter(|plugin| plugin.is_active())
|
||||
.enumerate()
|
||||
{
|
||||
let mut plugin_mcp_servers = plugin.mcp_servers.clone();
|
||||
filter_plugin_mcp_servers_by_requirements(
|
||||
@@ -1392,22 +1411,22 @@ impl Config {
|
||||
&mut plugin_mcp_servers,
|
||||
self.config_layer_stack.requirements().plugins.as_ref(),
|
||||
);
|
||||
filter_mcp_servers_by_requirements(&mut plugin_mcp_servers, empty_mcp_allowlist);
|
||||
for (name, plugin_server) in plugin_mcp_servers {
|
||||
if let Entry::Vacant(entry) = configured_mcp_servers.entry(name.clone()) {
|
||||
entry.insert(plugin_server);
|
||||
plugin_ids_by_mcp_server_name.insert(name, plugin.config_name.clone());
|
||||
}
|
||||
catalog.register(McpServerRegistration::from_plugin(
|
||||
name,
|
||||
plugin.config_name.clone(),
|
||||
plugin_order,
|
||||
plugin_server,
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(mcp_requirements) = self.config_layer_stack.requirements().mcp_servers.as_ref()
|
||||
&& mcp_requirements.value.is_empty()
|
||||
{
|
||||
// A present empty allowlist bans configurable MCPs, including plugin MCPs merged
|
||||
// above.
|
||||
filter_mcp_servers_by_requirements(&mut configured_mcp_servers, Some(mcp_requirements));
|
||||
for (name, server) in self.mcp_servers.get() {
|
||||
catalog.register(McpServerRegistration::from_config(
|
||||
name.clone(),
|
||||
server.clone(),
|
||||
));
|
||||
}
|
||||
plugin_ids_by_mcp_server_name
|
||||
.retain(|server_name, _| configured_mcp_servers.contains_key(server_name));
|
||||
|
||||
McpConfig {
|
||||
chatgpt_base_url: self.chatgpt_base_url.clone(),
|
||||
@@ -1434,8 +1453,7 @@ impl Config {
|
||||
// indicates this should be an empty object.
|
||||
ElicitationCapability::default()
|
||||
},
|
||||
configured_mcp_servers,
|
||||
plugin_ids_by_mcp_server_name,
|
||||
mcp_server_catalog: catalog.build(),
|
||||
plugin_capability_summaries: loaded_plugins.capability_summaries().to_vec(),
|
||||
}
|
||||
}
|
||||
@@ -2313,11 +2331,11 @@ fn resolve_code_mode_config(config_toml: &ConfigToml) -> CodeModeConfig {
|
||||
|
||||
fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config {
|
||||
let base = multi_agent_v2_toml_config(config_toml.features.as_ref());
|
||||
let default = MultiAgentV2Config::default();
|
||||
|
||||
let max_concurrent_threads_per_session = base
|
||||
.and_then(|config| config.max_concurrent_threads_per_session)
|
||||
.unwrap_or(default.max_concurrent_threads_per_session);
|
||||
.unwrap_or(DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION);
|
||||
let default =
|
||||
MultiAgentV2Config::defaults_for_max_concurrency(max_concurrent_threads_per_session);
|
||||
let min_wait_timeout_ms = base
|
||||
.and_then(|config| config.min_wait_timeout_ms)
|
||||
.unwrap_or(default.min_wait_timeout_ms);
|
||||
@@ -2592,12 +2610,6 @@ impl Config {
|
||||
.startup_warnings()
|
||||
.unwrap_or_default()
|
||||
.to_vec();
|
||||
let user_instructions = AgentsMdManager::load_global_instructions(
|
||||
LOCAL_FS.as_ref(),
|
||||
Some(&codex_home),
|
||||
&mut startup_warnings,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Destructure ConfigOverrides fully to ensure all overrides are applied.
|
||||
let ConfigOverrides {
|
||||
@@ -3436,7 +3448,6 @@ impl Config {
|
||||
approvals_reviewer: constrained_approvals_reviewer.value(),
|
||||
enforce_residency: enforce_residency.value,
|
||||
notify: cfg.notify,
|
||||
user_instructions,
|
||||
base_instructions,
|
||||
personality,
|
||||
developer_instructions,
|
||||
@@ -3651,8 +3662,9 @@ impl Config {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let path_uri = PathUri::from_abs_path(path)?;
|
||||
let contents = fs
|
||||
.read_file_text(path, /*sandbox*/ None)
|
||||
.read_file_text(&path_uri, /*sandbox*/ None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
std::io::Error::new(
|
||||
|
||||
@@ -43,6 +43,7 @@ use codex_mcp::codex_apps_tools_cache_key;
|
||||
use codex_mcp::compute_auth_statuses;
|
||||
use codex_mcp::effective_mcp_servers;
|
||||
use codex_mcp::host_owned_codex_apps_enabled;
|
||||
use codex_mcp::tool_plugin_provenance;
|
||||
|
||||
const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30);
|
||||
|
||||
@@ -251,7 +252,8 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
||||
});
|
||||
}
|
||||
let cache_key = accessible_connectors_cache_key(config, auth.as_ref());
|
||||
let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config).await;
|
||||
let mcp_config = mcp_manager.runtime_config(config).await;
|
||||
let tool_plugin_provenance = tool_plugin_provenance(&mcp_config);
|
||||
if !force_refetch && let Some(cached_connectors) = read_cached_accessible_connectors(&cache_key)
|
||||
{
|
||||
let cached_connectors = codex_connectors::filter::filter_disallowed_connectors(
|
||||
@@ -265,7 +267,6 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager(
|
||||
});
|
||||
}
|
||||
|
||||
let mcp_config = mcp_manager.runtime_config(config).await;
|
||||
let mut mcp_servers = effective_mcp_servers(&mcp_config, auth.as_ref());
|
||||
mcp_servers.retain(|name, _| name == CODEX_APPS_MCP_SERVER_NAME);
|
||||
let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(&mcp_config, auth.as_ref());
|
||||
|
||||
@@ -5,12 +5,23 @@ use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
|
||||
|
||||
use super::ContextualUserFragment;
|
||||
|
||||
/// Model-context fragment describing the skills available to Codex.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct AvailableSkillsInstructions {
|
||||
pub struct AvailableSkillsInstructions {
|
||||
skill_root_lines: Vec<String>,
|
||||
skill_lines: Vec<String>,
|
||||
}
|
||||
|
||||
impl AvailableSkillsInstructions {
|
||||
/// Creates a skills context fragment from pre-rendered catalog lines.
|
||||
pub fn from_skill_lines(skill_lines: Vec<String>) -> Self {
|
||||
Self {
|
||||
skill_root_lines: Vec::new(),
|
||||
skill_lines,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AvailableSkills> for AvailableSkillsInstructions {
|
||||
fn from(available_skills: AvailableSkills) -> Self {
|
||||
Self {
|
||||
|
||||
@@ -178,6 +178,7 @@ fn turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd() {
|
||||
network: None,
|
||||
file_system_sandbox_policy: None,
|
||||
model: "gpt-5".to_string(),
|
||||
comp_hash: None,
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
multi_agent_version: None,
|
||||
|
||||
@@ -23,6 +23,7 @@ mod realtime_end_instructions;
|
||||
mod realtime_start_instructions;
|
||||
mod realtime_start_with_instructions;
|
||||
mod subagent_notification;
|
||||
mod token_budget_context;
|
||||
mod turn_aborted;
|
||||
mod user_instructions;
|
||||
mod user_shell_command;
|
||||
@@ -30,7 +31,7 @@ mod user_shell_command;
|
||||
pub(crate) use approved_command_prefix_saved::ApprovedCommandPrefixSaved;
|
||||
pub(crate) use apps_instructions::AppsInstructions;
|
||||
pub(crate) use available_plugins_instructions::AvailablePluginsInstructions;
|
||||
pub(crate) use available_skills_instructions::AvailableSkillsInstructions;
|
||||
pub use available_skills_instructions::AvailableSkillsInstructions;
|
||||
pub(crate) use codex_context_fragments::AdditionalContextDeveloperFragment;
|
||||
pub(crate) use codex_context_fragments::AdditionalContextUserFragment;
|
||||
pub use codex_context_fragments::ContextualUserFragment;
|
||||
@@ -60,6 +61,8 @@ pub(crate) use realtime_end_instructions::RealtimeEndInstructions;
|
||||
pub(crate) use realtime_start_instructions::RealtimeStartInstructions;
|
||||
pub(crate) use realtime_start_with_instructions::RealtimeStartWithInstructions;
|
||||
pub(crate) use subagent_notification::SubagentNotification;
|
||||
pub(crate) use token_budget_context::TokenBudgetContext;
|
||||
pub(crate) use token_budget_context::TokenBudgetRemainingContext;
|
||||
pub(crate) use turn_aborted::TurnAborted;
|
||||
pub(crate) use user_instructions::UserInstructions;
|
||||
pub(crate) use user_shell_command::UserShellCommand;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user